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: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ dotnet test tests/Workflow.Tests/Workflow.Tests.csproj
- **Activity methods**: Must be `public virtual async` in implementation class (Temporal requirement)
- **String truncation**: Use `StringExtensions.Truncate(maxLength)` instead of inline `[..Math.Min()]` patterns. Located in `src/Common/Extensions/StringExtensions.cs`.
- **Quote expiry enforcement**: `ValidateQuoteAsync` checks `QuoteExpiry < Workflow.UtcNow` after HMAC signature validation to prevent replay of stale signed quotes
- **IsTestnet**: `Network.IsTestnet` is set at creation time only (not editable via update). `NetworkDto.IsTestnet` propagated through all projections. AdminPanel defaults to hiding testnets with a toggle.

## JS Project (`js/`)

Expand Down
16 changes: 8 additions & 8 deletions csharp/src/AdminAPI/Endpoints/NetworkEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Train.Solver.AdminAPI.Filters;
using Train.Solver.Infrastructure.Abstractions;
using Train.Solver.Infrastructure.Services;
using Train.Solver.Common.Extensions;
using Train.Solver.Data.Abstractions.Models;
Expand Down Expand Up @@ -34,8 +35,7 @@ public static RouteGroupBuilder MapNetworkEndpoints(this RouteGroupBuilder group
.Produces<NodeDto>()
.Produces(StatusCodes.Status400BadRequest);

group.MapDelete("/networks/{networkName}/nodes/{providerName}", DeleteNodeAsync)
.Produces<NodeDto>()
group.MapDelete("/networks/{networkName}/nodes/{id:int}", DeleteNodeAsync)
.Produces(StatusCodes.Status204NoContent);

group.MapPost("/networks/{networkName}/tokens", CreateTokenAsync)
Expand Down Expand Up @@ -115,18 +115,18 @@ public static RouteGroupBuilder MapNetworkEndpoints(this RouteGroupBuilder group

private static async Task<IResult> GetAllAsync(
ILogger<Program> logger,
INetworkRepository repository,
INetworkService networkService,
[FromQuery] string[]? types)
{
var networks = await repository.GetAllAsync(types.IsNullOrEmpty() ? null : types);
var networks = await networkService.GetAllAsync(types.IsNullOrEmpty() ? null : types);
return Results.Ok(networks);
}

private static async Task<IResult> GetAsync(
INetworkRepository repository,
INetworkService networkService,
string networkName)
{
var network = await repository.GetAsync(networkName);
var network = await networkService.GetAsync(networkName);
return network is null
? Results.NotFound($"Network '{networkName}' not found.")
: Results.Ok(network);
Expand Down Expand Up @@ -188,9 +188,9 @@ private static async Task<IResult> CreateNodeAsync(
private static async Task<IResult> DeleteNodeAsync(
INetworkRepository repository,
string networkName,
string providerName)
int id)
{
await repository.DeleteNodeAsync(networkName, providerName);
await repository.DeleteNodeAsync(id);
return Results.Ok();
}

Expand Down
155 changes: 155 additions & 0 deletions csharp/src/AdminAPI/Endpoints/NodeProviderEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Mvc;
using Train.Solver.Data.Abstractions.Models;
using Train.Solver.Data.Abstractions.Repositories;
using Train.Solver.Infrastructure.Abstractions;
using Train.Solver.Shared.Models;

namespace Train.Solver.AdminAPI.Endpoints;

public static class NodeProviderEndpoints
{
public static RouteGroupBuilder MapNodeProviderEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/node-providers", GetAllAsync)
.Produces<List<NodeProviderDto>>(StatusCodes.Status200OK);

group.MapGet("/node-providers/{id:int}", GetAsync)
.Produces<NodeProviderDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

group.MapPost("/node-providers", CreateAsync)
.Produces<NodeProviderDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);

group.MapPut("/node-providers/{id:int}", UpdateAsync)
.Produces<NodeProviderDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

group.MapDelete("/node-providers/{id:int}", DeleteAsync)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

group.MapPost("/node-providers/{id:int}/networks/{networkSlug}", LinkNetworkAsync)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);

group.MapDelete("/node-providers/{id:int}/networks/{networkSlug}", UnlinkNetworkAsync)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

group.MapGet("/node-providers/supported-chains/{providerName}", GetSupportedChainsAsync)
.Produces<List<string>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

return group;
}

private static async Task<IResult> GetAllAsync(
INodeProviderRepository repository)
{
var providers = await repository.GetAllAsync();
return Results.Ok(providers);
}

private static async Task<IResult> GetAsync(
int id,
INodeProviderRepository repository)
{
var provider = await repository.GetAsync(id);
return provider is null ? Results.NotFound("Node provider not found") : Results.Ok(provider);
}

private static async Task<IResult> CreateAsync(
INodeProviderRepository repository,
IEnumerable<INodeProvider> nodeProviders,
[FromBody] CreateNodeProviderRequest request)
{
var knownProvider = nodeProviders.FirstOrDefault(p =>
string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase));

if (knownProvider is null)
{
var knownNames = nodeProviders.Select(p => p.Name);
return Results.BadRequest($"Unknown provider '{request.Name}'. Known providers: {string.Join(", ", knownNames)}");
}

request.Name = knownProvider.Name;
var provider = await repository.CreateAsync(request);
return Results.Ok(provider);
}

private static async Task<IResult> UpdateAsync(
int id,
INodeProviderRepository repository,
[FromBody] UpdateNodeProviderRequest request)
{
var provider = await repository.UpdateAsync(id, request);
return provider is null ? Results.NotFound("Node provider not found") : Results.Ok(provider);
}

private static async Task<IResult> DeleteAsync(
int id,
INodeProviderRepository repository)
{
var deleted = await repository.DeleteAsync(id);
return deleted ? Results.Ok() : Results.NotFound("Node provider not found");
}

private static async Task<IResult> LinkNetworkAsync(
int id,
string networkSlug,
INodeProviderRepository providerRepository,
INetworkRepository networkRepository,
IEnumerable<INodeProvider> nodeProviders)
{
var provider = await providerRepository.GetAsync(id);
if (provider is null)
return Results.NotFound("Node provider not found");

var network = await networkRepository.GetAsync(networkSlug);
if (network is null)
return Results.NotFound($"Network '{networkSlug}' not found");

var codeProvider = nodeProviders.FirstOrDefault(p =>
string.Equals(p.Name, provider.Name, StringComparison.OrdinalIgnoreCase));

if (codeProvider is not null && !codeProvider.SupportsChain(network.ChainId))
return Results.BadRequest($"Provider '{provider.Name}' does not support chain ID '{network.ChainId}'");

var linked = await providerRepository.LinkNetworkAsync(id, networkSlug);
return linked ? Results.Ok() : Results.BadRequest("Failed to link network");
}

private static async Task<IResult> UnlinkNetworkAsync(
int id,
string networkSlug,
INodeProviderRepository repository)
{
var unlinked = await repository.UnlinkNetworkAsync(id, networkSlug);
return unlinked ? Results.Ok() : Results.NotFound("Link not found");
}

private static IResult GetSupportedChainsAsync(
string providerName,
IEnumerable<INodeProvider> nodeProviders)
{
var provider = nodeProviders.FirstOrDefault(p =>
string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));

if (provider is null)
return Results.NotFound($"Unknown provider '{providerName}'");

// Return supported chains by testing known EVM chain IDs
var knownChainIds = new[]
{
"1", "11155111", "42161", "421614", "8453", "84532",
"10", "11155420", "137", "80002", "43114", "43113",
"56", "97", "534352", "534351", "324", "300",
"81457", "168587773", "59144", "59141",
};

var supported = knownChainIds.Where(provider.SupportsChain).ToList();
return Results.Ok(supported);
}
}
1 change: 1 addition & 0 deletions csharp/src/AdminAPI/Endpoints/RebalanceEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ private static async Task<IResult> RebalanceAsync(
ChainId = network.ChainId,
Type = network.Type.Name,
DisplayName = network.DisplayName,
IsTestnet = network.IsTestnet,
NativeTokenAddress = network.Type.NativeTokenAddress,
},
Token = token,
Expand Down
5 changes: 5 additions & 0 deletions csharp/src/AdminAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@
.RequireRateLimiting("Fixed")
.WithTags("Rebalance");

app.MapGroup("/api")
.MapNodeProviderEndpoints()
.RequireRateLimiting("Fixed")
.WithTags("Node Provider");

app.MapGroup("/api")
.MapTransactionBuilderEndpoints()
.RequireRateLimiting("Fixed")
Expand Down
2 changes: 0 additions & 2 deletions csharp/src/AdminAPI/Validators/NetworkValidators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public CreateNetworkRequestValidator()
RuleFor(x => x.NativeTokenPriceSymbol).NotEmpty();
RuleFor(x => x.NativeTokenDecimals).GreaterThanOrEqualTo(0);
RuleFor(x => x.NodeUrl).NotEmpty();
RuleFor(x => x.NodeProvider).NotEmpty();
RuleFor(x => x.BaseFeePercentageIncrease).GreaterThanOrEqualTo(0);
RuleFor(x => x.PriorityFeePercentageIncrease).GreaterThanOrEqualTo(0);
RuleFor(x => x.ReplacementFeePercentageIncrease).GreaterThanOrEqualTo(0);
Expand Down Expand Up @@ -47,7 +46,6 @@ public class CreateNodeRequestValidator : AbstractValidator<CreateNodeRequest>
{
public CreateNodeRequestValidator()
{
RuleFor(x => x.ProviderName).NotEmpty();
RuleFor(x => x.Url).NotEmpty()
.Must(url => Uri.TryCreate(url, UriKind.Absolute, out _))
.WithMessage("Url must be a valid absolute URI.");
Expand Down
34 changes: 34 additions & 0 deletions csharp/src/AdminPanel/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,40 @@
<body>
<Routes @rendermode="RenderMode.InteractiveServer" />

<div id="components-reconnect-modal">
<div class="reconnect-overlay"></div>
<div class="reconnect-dialog">
<div class="reconnect-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 1l22 22" class="reconnect-x-line" />
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" class="reconnect-wave" />
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" class="reconnect-wave" />
<path d="M10.71 5.05A16 16 0 0 1 22.56 9" class="reconnect-wave" />
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" class="reconnect-wave" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" class="reconnect-wave" />
<line x1="12" y1="20" x2="12.01" y2="20" class="reconnect-dot" />
</svg>
</div>
<div class="reconnect-content">
<h3 class="reconnect-title show-when-reconnecting">Reconnecting to server...</h3>
<p class="reconnect-message show-when-reconnecting">Connection lost. Attempting to reconnect.</p>

<h3 class="reconnect-title show-when-failed">Connection failed</h3>
<p class="reconnect-message show-when-failed">Unable to reach the server. Please check your connection.</p>

<h3 class="reconnect-title show-when-rejected">Session expired</h3>
<p class="reconnect-message show-when-rejected">The server session has ended. Reload to continue.</p>
</div>
<div class="reconnect-actions">
<a href="." class="reconnect-btn show-when-failed">Retry</a>
<a href="." class="reconnect-btn show-when-rejected">Reload</a>
</div>
<div class="reconnect-spinner show-when-reconnecting">
<div class="spinner-ring"></div>
</div>
</div>
</div>

<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
Expand Down
5 changes: 5 additions & 0 deletions csharp/src/AdminPanel/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
<span class="bi bi-wallet-nav-menu" aria-hidden="true"></span> Trusted Wallets
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="node-providers">
<span class="bi bi-diagram-nav-menu" aria-hidden="true"></span> Node Providers
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="webhooks">
<span class="bi bi-bell-nav-menu" aria-hidden="true"></span> Webhooks
Expand Down
Loading
Loading