diff --git a/README.md b/README.md
index 0283866..8b23a32 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
-
+
@@ -32,7 +32,7 @@
## 📖 Overview
-The Cardano Open Metadata Project (COMP) is a unified metadata specification and high-performance reference implementation for the Cardano blockchain. COMP defines standardized REST APIs that aggregate all Cardano metadata types - tokens, NFTs, stake pools, and DReps - from multiple sources into a single, queryable interface. Built with modern .NET 9.0 and PostgreSQL, our reference implementation demonstrates sub-millisecond query performance while establishing COMP as the definitive metadata standard for the Cardano ecosystem.
+The Cardano Open Metadata Project (COMP) is a unified metadata specification and high-performance reference implementation for the Cardano blockchain. COMP defines standardized REST APIs that aggregate Cardano metadata from multiple sources into a single, queryable interface. Built with modern .NET 10.0 and PostgreSQL, the current implementation focuses on token registry and on-chain token metadata (CIP-25/CIP-68).
**Key Features:**
@@ -82,7 +82,7 @@ graph LR
### Prerequisites
-- .NET 9.0 SDK
+- .NET 10.0 SDK
- PostgreSQL 15+
- GitHub Personal Access Token (for API rate limits)
@@ -112,18 +112,31 @@ cd COMP
}
```
-3. **Run database migrations**
+3. **Start local dependencies (why `docker-compose.yml` is in repo root)**
+```bash
+docker compose up -d db
+```
+
+The root `docker-compose.yml` is used for local development orchestration. It starts the shared PostgreSQL service and the API container from the same repository context, so Docker can access `src/` and `infrastructure/` paths directly.
+
+4. **Run database migrations** (optional if running `COMP.Sync`, which applies migrations on startup)
```bash
dotnet ef database update
```
-4. **Start the service**
+5. **Start the API service**
```bash
-dotnet run
+dotnet run --project src/COMP.API
```
-The service will start syncing with the GitHub Token Registry and expose APIs on:
-- `https://localhost:7276` (HTTPS) or `http://localhost:5146` (HTTP)
+6. **(Optional) Start the sync worker**
+```bash
+dotnet run --project src/COMP.Sync
+```
+
+API docs in development:
+- `http://localhost:5001/scalar`
+- `http://localhost:5001/openapi/v1.json`
## 📡 API Endpoints
@@ -268,7 +281,7 @@ COMP implementations must handle:
Our reference implementation demonstrates the COMP specification using:
### Core Technologies
-- **.NET 9.0** - High-performance runtime with modern C# features
+- **.NET 10.0** - High-performance runtime with modern C# features
- **PostgreSQL** - Scalable database for metadata storage
- **FastEndpoints** - Lightweight API framework for REST endpoints
@@ -326,6 +339,14 @@ COMP implementations must include:
- **Secrets Management** - Secure storage for credentials and keys
- **CORS Policy** - Configure appropriate cross-origin policies
+## 🖼️ Image Upload Service Status
+
+Image upload/caching is implemented on the feature branch `feat/api/image-upload-service`.
+
+- Reference: `docs/IMAGE_UPLOAD_SERVICE.md`
+- Implementation branch: `feat/api/image-upload-service`
+- Mainline note: if this branch is not merged into your current checkout, image upload will not be active at runtime.
+
## 🤝 Contributing
We welcome contributions! Please follow these steps:
@@ -351,4 +372,4 @@ We welcome contributions! Please follow these steps:
Request Feature •
Documentation
-
\ No newline at end of file
+
diff --git a/docker-compose.yml b/docker-compose.yml
index c5bb2d4..8bc1400 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,7 +20,7 @@ services:
app:
build:
context: .
- dockerfile: Dockerfile
+ dockerfile: infrastructure/comp-api/Dockerfile
container_name: comp-app
restart: unless-stopped
depends_on:
diff --git a/infrastructure/comp-api/Dockerfile b/infrastructure/comp-api/Dockerfile
index ff1ccd3..50f9f5c 100644
--- a/infrastructure/comp-api/Dockerfile
+++ b/infrastructure/comp-api/Dockerfile
@@ -18,7 +18,7 @@ COPY infrastructure/comp-api/production-config.json ./COMP.API/appsettings.json
RUN dotnet publish COMP.API/COMP.API.csproj -c Release -o /app/publish
# Runtime stage
-FROM mcr.microsoft.com/dotnet/aspnet:9.0
+FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
# Copy published app
@@ -32,4 +32,4 @@ ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
# Run the application
-ENTRYPOINT ["dotnet", "COMP.API.dll"]
\ No newline at end of file
+ENTRYPOINT ["dotnet", "COMP.API.dll"]
diff --git a/infrastructure/comp-sync/Dockerfile b/infrastructure/comp-sync/Dockerfile
index fc0d974..827dc80 100644
--- a/infrastructure/comp-sync/Dockerfile
+++ b/infrastructure/comp-sync/Dockerfile
@@ -4,7 +4,6 @@ WORKDIR /src
# Copy project files and restore
COPY src/COMP.Data/COMP.Data.csproj src/COMP.Data/
-COPY src/COMP.API/COMP.API.csproj src/COMP.API/
COPY src/COMP.Sync/COMP.Sync.csproj src/COMP.Sync/
RUN dotnet restore "src/COMP.Sync/COMP.Sync.csproj"
diff --git a/src/COMP.API/COMP.API.csproj b/src/COMP.API/COMP.API.csproj
index a30b77d..f0bebf3 100644
--- a/src/COMP.API/COMP.API.csproj
+++ b/src/COMP.API/COMP.API.csproj
@@ -5,15 +5,17 @@
enable
enable
COMP.API
- Comp
+ COMP.API
-
-
+
+
+
-
+
+
diff --git a/src/COMP.API/Endpoints/BatchTokenMetadataEndpoint.cs b/src/COMP.API/Endpoints/BatchTokenMetadataEndpoint.cs
index 72e7640..f738a52 100644
--- a/src/COMP.API/Endpoints/BatchTokenMetadataEndpoint.cs
+++ b/src/COMP.API/Endpoints/BatchTokenMetadataEndpoint.cs
@@ -1,5 +1,5 @@
using FastEndpoints;
-using COMP.API.Modules.Handlers;
+using COMP.API.Handlers;
using COMP.Data.Models.Request;
namespace COMP.API.Endpoints;
@@ -14,8 +14,7 @@ public override void Configure()
AllowAnonymous();
Description(b => b
.WithName("BatchTokenMetadata")
- .WithSummary("Retrieve token metadata for a batch of subjects")
- .WithTags("Metadata"));
+ .WithSummary("Retrieve token metadata for a batch of subjects"));
}
public override async Task HandleAsync(BatchTokenMetadataRequest req, CancellationToken ct)
@@ -29,11 +28,9 @@ public override async Task HandleAsync(BatchTokenMetadataRequest req, Cancellati
req.Offset,
req.IncludeEmptyName,
req.IncludeEmptyLogo,
- req.IncludeEmptyTicker);
-
- if (result is IResult httpResult)
- {
- await SendResultAsync(httpResult);
- }
+ req.IncludeEmptyTicker,
+ ct);
+
+ await result.ExecuteAsync(HttpContext);
}
}
diff --git a/src/COMP.API/Endpoints/GetTokenMetadataEndpoint.cs b/src/COMP.API/Endpoints/GetTokenMetadataEndpoint.cs
index c388166..488e692 100644
--- a/src/COMP.API/Endpoints/GetTokenMetadataEndpoint.cs
+++ b/src/COMP.API/Endpoints/GetTokenMetadataEndpoint.cs
@@ -1,5 +1,5 @@
using FastEndpoints;
-using COMP.API.Modules.Handlers;
+using COMP.API.Handlers;
using COMP.Data.Models.Request;
namespace COMP.API.Endpoints;
@@ -14,17 +14,12 @@ public override void Configure()
AllowAnonymous();
Description(b => b
.WithName("GetTokenMetadata")
- .WithSummary("Retrieve token metadata by subject")
- .WithTags("Metadata"));
+ .WithSummary("Retrieve token metadata by subject"));
}
public override async Task HandleAsync(GetTokenMetadataRequest req, CancellationToken ct)
{
- IResult result = await _metadataHandler.GetTokenMetadataAsync(req.Subject);
-
- if (result is IResult httpResult)
- {
- await SendResultAsync(httpResult);
- }
+ IResult result = await _metadataHandler.GetTokenMetadataAsync(req.Subject, ct);
+ await result.ExecuteAsync(HttpContext);
}
}
diff --git a/src/COMP.API/Modules/Handlers/MetadataHandler.cs b/src/COMP.API/Handlers/MetadataHandler.cs
similarity index 71%
rename from src/COMP.API/Modules/Handlers/MetadataHandler.cs
rename to src/COMP.API/Handlers/MetadataHandler.cs
index c469182..d9a4ca3 100644
--- a/src/COMP.API/Modules/Handlers/MetadataHandler.cs
+++ b/src/COMP.API/Handlers/MetadataHandler.cs
@@ -1,34 +1,32 @@
+using COMP.Data.Data;
using COMP.Data.Models.Entity;
-using Microsoft.EntityFrameworkCore;
using LinqKit;
-using COMP.Data.Data;
+using Microsoft.EntityFrameworkCore;
-namespace COMP.API.Modules.Handlers;
+namespace COMP.API.Handlers;
-public class MetadataHandler
-(
- IDbContextFactory _dbContextFactory
-)
+public class MetadataHandler(IDbContextFactory _dbContextFactory)
{
// Fetch data by subject (checks both registry and on-chain tables)
- public async Task GetTokenMetadataAsync(string subject)
+ public async Task GetTokenMetadataAsync(string subject, CancellationToken cancellationToken = default)
{
- await using MetadataDbContext db = await _dbContextFactory.CreateDbContextAsync();
+ await using MetadataDbContext db = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Query both tables sequentially
- TokenMetadata? registryToken = await db.TokenMetadata
+ TokenMetadataRegistry? registryToken = await db.TokenMetadataRegistry
.AsNoTracking()
- .FirstOrDefaultAsync(t => t.Subject == subject);
+ .FirstOrDefaultAsync(t => t.Subject == subject, cancellationToken);
TokenMetadataOnChain? onChainToken = await db.TokenMetadataOnChain
.AsNoTracking()
- .FirstOrDefaultAsync(t => t.Subject == subject);
+ .FirstOrDefaultAsync(t => t.Subject == subject, cancellationToken);
// If both are null, return 404
if (registryToken is null && onChainToken is null)
return Results.NotFound();
// Prioritize on-chain data, fall back to registry
+ string? logo = onChainToken?.Logo ?? registryToken?.Logo;
return Results.Ok(new
{
@@ -36,7 +34,7 @@ public async Task GetTokenMetadataAsync(string subject)
policyId = onChainToken?.PolicyId ?? registryToken?.PolicyId ?? "",
name = onChainToken?.Name ?? registryToken?.Name,
ticker = registryToken?.Ticker,
- logo = onChainToken?.Logo ?? registryToken?.Logo,
+ logo,
description = onChainToken?.Description ?? registryToken?.Description,
decimals = onChainToken?.Decimals ?? registryToken?.Decimals ?? 0,
quantity = onChainToken?.Quantity,
@@ -59,7 +57,8 @@ public async Task BatchTokenMetadataAsync(
int? offset,
bool? includeEmptyName,
bool? includeEmptyLogo,
- bool? includeEmptyTicker)
+ bool? includeEmptyTicker,
+ CancellationToken cancellationToken = default)
{
if (subjects == null || subjects.Count == 0)
return Results.BadRequest("No subjects provided.");
@@ -72,15 +71,15 @@ public async Task BatchTokenMetadataAsync(
List distinctSubjects = [.. subjects.Distinct()];
// Build predicate for registry metadata
- ExpressionStarter registryPredicate = PredicateBuilder.New(false);
+ ExpressionStarter registryPredicate = PredicateBuilder.New(false);
registryPredicate = registryPredicate.Or(token => distinctSubjects.Contains(token.Subject));
- if (!string.IsNullOrWhiteSpace(policyId))
- {
- string lowerPolicyId = policyId.ToLowerInvariant();
- registryPredicate = registryPredicate.And(token =>
- token.Subject.Substring(0, 56).Equals(lowerPolicyId, StringComparison.CurrentCultureIgnoreCase));
- }
+ string? normalizedPolicyId = string.IsNullOrWhiteSpace(policyId)
+ ? null
+ : policyId.Trim().ToLowerInvariant();
+
+ if (!string.IsNullOrWhiteSpace(normalizedPolicyId))
+ registryPredicate = registryPredicate.And(token => token.Subject.StartsWith(normalizedPolicyId));
if (requireName)
registryPredicate = registryPredicate.And(token => !string.IsNullOrEmpty(token.Name));
@@ -105,9 +104,9 @@ public async Task BatchTokenMetadataAsync(
ExpressionStarter onChainPredicate = PredicateBuilder.New(false);
onChainPredicate = onChainPredicate.Or(token => distinctSubjects.Contains(token.Subject));
- if (!string.IsNullOrWhiteSpace(policyId))
+ if (!string.IsNullOrWhiteSpace(normalizedPolicyId))
{
- onChainPredicate = onChainPredicate.And(token => token.PolicyId == policyId);
+ onChainPredicate = onChainPredicate.And(token => token.PolicyId == normalizedPolicyId);
}
if (requireName)
onChainPredicate = onChainPredicate.And(token => !string.IsNullOrEmpty(token.Name));
@@ -122,32 +121,36 @@ public async Task BatchTokenMetadataAsync(
(token.Description != null && EF.Functions.ILike(token.Description, $"%{searchText}%")));
}
- await using MetadataDbContext db = await _dbContextFactory.CreateDbContextAsync();
+ await using MetadataDbContext db = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
- // Query both tables sequentially
- List registryTokens = await db.TokenMetadata
+ // Query both tables sequentially (DbContext is not thread-safe)
+ List registryTokens = await db.TokenMetadataRegistry
.AsNoTracking()
.Where(registryPredicate)
- .ToListAsync();
+ .ToListAsync(cancellationToken);
List onChainTokens = await db.TokenMetadataOnChain
.AsNoTracking()
.Where(onChainPredicate)
- .ToListAsync();
+ .ToListAsync(cancellationToken);
+
+ // Convert to dictionaries for O(1) lookups instead of O(n) FirstOrDefault
+ Dictionary registryBySubject = registryTokens.ToDictionary(t => t.Subject);
+ Dictionary onChainBySubject = onChainTokens.ToDictionary(t => t.Subject);
// Merge results - prioritize on-chain, combine with registry
var mergedResults = distinctSubjects
.Select(subject =>
{
- TokenMetadata? registry = registryTokens.FirstOrDefault(t => t.Subject == subject);
- TokenMetadataOnChain? onChain = onChainTokens.FirstOrDefault(t => t.Subject == subject);
+ registryBySubject.TryGetValue(subject, out TokenMetadataRegistry? registry);
+ onChainBySubject.TryGetValue(subject, out TokenMetadataOnChain? onChain);
if (registry is null && onChain is null)
return null;
return new
{
- subject = subject,
+ subject,
policyId = onChain?.PolicyId ?? registry?.PolicyId ?? "",
name = onChain?.Name ?? registry?.Name,
ticker = registry?.Ticker,
@@ -164,15 +167,18 @@ public async Task BatchTokenMetadataAsync(
};
})
.Where(t => t is not null)
+ .Select(t => t!)
.ToList();
+ if (requireTicker)
+ mergedResults = [.. mergedResults.Where(t => !string.IsNullOrEmpty(t.ticker))];
+
int total = mergedResults.Count;
- // Apply pagination
+ // Apply pagination: offset always applies, limit is optional
+ mergedResults = [.. mergedResults.Skip(effectiveOffset)];
if (limit.HasValue)
- {
- mergedResults = mergedResults.Skip(effectiveOffset).Take(limit.Value).ToList();
- }
+ mergedResults = [.. mergedResults.Take(limit.Value)];
if (mergedResults.Count == 0)
return Results.NotFound("No tokens found for the given subjects.");
diff --git a/src/COMP.API/Program.cs b/src/COMP.API/Program.cs
index 8739614..8145ac8 100644
--- a/src/COMP.API/Program.cs
+++ b/src/COMP.API/Program.cs
@@ -3,7 +3,7 @@
using Scalar.AspNetCore;
using Microsoft.EntityFrameworkCore;
using COMP.Data.Data;
-using COMP.API.Modules.Handlers;
+using COMP.API.Handlers;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -11,38 +11,25 @@
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddSingleton();
-
builder.Services.AddFastEndpoints();
-builder.Services.SwaggerDocument(o =>
-{
- o.DocumentSettings = s =>
- {
- s.Title = "COMP API";
- s.Version = "v1";
- };
-});
-
-builder.Services.AddOpenApi();
+builder.Services.SwaggerDocument();
WebApplication app = builder.Build();
+app.UseFastEndpoints();
+
if (app.Environment.IsDevelopment())
{
- app.MapOpenApi();
+ app.UseSwaggerGen(options =>
+ {
+ options.Path = "/openapi/{documentName}.json";
+ });
+ app.MapScalarApiReference(options =>
+ {
+ options.Title = "COMP API Documentation";
+ options.Theme = ScalarTheme.Default;
+ options.ShowSidebar = true;
+ });
}
-app.UseHttpsRedirection();
-
-app.UseFastEndpoints();
-
-app.UseSwaggerGen();
-
-app.MapScalarApiReference(options =>
- options
- .WithTitle("COMP API")
- .WithTheme(ScalarTheme.Purple)
- .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
- .WithOpenApiRoutePattern("/swagger/{documentName}/swagger.json")
-);
-
app.Run();
\ No newline at end of file
diff --git a/src/COMP.API/Properties/launchSettings.json b/src/COMP.API/Properties/launchSettings.json
new file mode 100644
index 0000000..27465e3
--- /dev/null
+++ b/src/COMP.API/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "COMP.API": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "http://localhost:5001"
+ }
+ }
+}
diff --git a/src/COMP.API/appsettings.example.json b/src/COMP.API/appsettings.example.json
new file mode 100644
index 0000000..6d79722
--- /dev/null
+++ b/src/COMP.API/appsettings.example.json
@@ -0,0 +1,24 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=cardano_metadata;Username=postgres;Password=postgres"
+ },
+ "AllowedHosts": "*",
+ "S3": {
+ "Enabled": false,
+ "BucketName": "my-token-logos",
+ "Region": "us-east-1",
+ "AccessKey": "",
+ "SecretKey": "",
+ "ServiceUrl": null,
+ "PublicBaseUrl": null,
+ "IpfsGateway": null,
+ "ArweaveGateway": null,
+ "MaxParallelUploads": 4
+ }
+}
diff --git a/src/COMP.Data/Data/MetadataDbContext.cs b/src/COMP.Data/Data/MetadataDbContext.cs
index 6bde345..c14ed9a 100644
--- a/src/COMP.Data/Data/MetadataDbContext.cs
+++ b/src/COMP.Data/Data/MetadataDbContext.cs
@@ -7,20 +7,20 @@ namespace COMP.Data.Data;
public class MetadataDbContext(DbContextOptions options, IConfiguration configuration) : CardanoDbContext(options, configuration)
{
- public DbSet TokenMetadata => Set();
- public DbSet SyncState => Set();
+ public DbSet TokenMetadataRegistry => Set();
+ public DbSet RegistrySyncState => Set();
public DbSet TokenMetadataOnChain => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
- modelBuilder.Entity().HasKey(tmd => tmd.Subject);
- modelBuilder.Entity()
+ modelBuilder.Entity().HasKey(tmd => tmd.Subject);
+ modelBuilder.Entity()
.HasIndex(tmd => new { tmd.Name, tmd.Description, tmd.Ticker })
- .HasDatabaseName("IX_TokenMetadata_Name_Description_Ticker");
+ .HasDatabaseName("IX_TokenMetadataRegistry_Name_Description_Ticker");
- modelBuilder.Entity().HasKey(ss => ss.Hash);
+ modelBuilder.Entity().HasKey(ss => ss.Hash);
modelBuilder.Entity(entity =>
{
diff --git a/src/COMP.Data/Migrations/20260130162627_RenameRegistryTables.Designer.cs b/src/COMP.Data/Migrations/20260130162627_RenameRegistryTables.Designer.cs
new file mode 100644
index 0000000..c437db0
--- /dev/null
+++ b/src/COMP.Data/Migrations/20260130162627_RenameRegistryTables.Designer.cs
@@ -0,0 +1,146 @@
+//
+using System;
+using COMP.Data.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Comp.Migrations
+{
+ [DbContext(typeof(MetadataDbContext))]
+ [Migration("20260130162627_RenameRegistryTables")]
+ partial class RenameRegistryTables
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("public")
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Argus.Sync.Data.Models.ReducerState", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LatestIntersectionsJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("StartIntersectionJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Name");
+
+ b.ToTable("ReducerStates", "public");
+ });
+
+ modelBuilder.Entity("COMP.Data.Models.Entity.RegistrySyncState", b =>
+ {
+ b.Property("Hash")
+ .HasColumnType("text");
+
+ b.Property("Date")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Hash");
+
+ b.ToTable("RegistrySyncState", "public");
+ });
+
+ modelBuilder.Entity("COMP.Data.Models.Entity.TokenMetadataOnChain", b =>
+ {
+ b.Property("Subject")
+ .HasColumnType("text");
+
+ b.Property("AssetName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Decimals")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Logo")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PolicyId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Quantity")
+ .HasColumnType("bigint");
+
+ b.Property("TokenType")
+ .HasColumnType("integer");
+
+ b.HasKey("Subject");
+
+ b.HasIndex("PolicyId");
+
+ b.ToTable("TokenMetadataOnChain", "public");
+ });
+
+ modelBuilder.Entity("COMP.Data.Models.Entity.TokenMetadataRegistry", b =>
+ {
+ b.Property("Subject")
+ .HasColumnType("text");
+
+ b.Property("Decimals")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Logo")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Policy")
+ .HasColumnType("text");
+
+ b.Property("PolicyId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Ticker")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Url")
+ .HasColumnType("text");
+
+ b.HasKey("Subject");
+
+ b.HasIndex("Name", "Description", "Ticker")
+ .HasDatabaseName("IX_TokenMetadataRegistry_Name_Description_Ticker");
+
+ b.ToTable("TokenMetadataRegistry", "public");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/COMP.Data/Migrations/20260130162627_RenameRegistryTables.cs b/src/COMP.Data/Migrations/20260130162627_RenameRegistryTables.cs
new file mode 100644
index 0000000..b57c5e4
--- /dev/null
+++ b/src/COMP.Data/Migrations/20260130162627_RenameRegistryTables.cs
@@ -0,0 +1,84 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Comp.Migrations
+{
+ ///
+ public partial class RenameRegistryTables : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Rename tables
+ migrationBuilder.RenameTable(
+ name: "SyncState",
+ schema: "public",
+ newName: "RegistrySyncState",
+ newSchema: "public");
+
+ migrationBuilder.RenameTable(
+ name: "TokenMetadata",
+ schema: "public",
+ newName: "TokenMetadataRegistry",
+ newSchema: "public");
+
+ // Rename primary key constraints
+ migrationBuilder.RenameIndex(
+ name: "PK_SyncState",
+ schema: "public",
+ table: "RegistrySyncState",
+ newName: "PK_RegistrySyncState");
+
+ migrationBuilder.RenameIndex(
+ name: "PK_TokenMetadata",
+ schema: "public",
+ table: "TokenMetadataRegistry",
+ newName: "PK_TokenMetadataRegistry");
+
+ // Rename existing index
+ migrationBuilder.RenameIndex(
+ name: "IX_TokenMetadata_Name_Description_Ticker",
+ schema: "public",
+ table: "TokenMetadataRegistry",
+ newName: "IX_TokenMetadataRegistry_Name_Description_Ticker");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // Rename tables back
+ migrationBuilder.RenameTable(
+ name: "RegistrySyncState",
+ schema: "public",
+ newName: "SyncState",
+ newSchema: "public");
+
+ migrationBuilder.RenameTable(
+ name: "TokenMetadataRegistry",
+ schema: "public",
+ newName: "TokenMetadata",
+ newSchema: "public");
+
+ // Rename primary key constraints back
+ migrationBuilder.RenameIndex(
+ name: "PK_RegistrySyncState",
+ schema: "public",
+ table: "SyncState",
+ newName: "PK_SyncState");
+
+ migrationBuilder.RenameIndex(
+ name: "PK_TokenMetadataRegistry",
+ schema: "public",
+ table: "TokenMetadata",
+ newName: "PK_TokenMetadata");
+
+ // Rename index back
+ migrationBuilder.RenameIndex(
+ name: "IX_TokenMetadataRegistry_Name_Description_Ticker",
+ schema: "public",
+ table: "TokenMetadata",
+ newName: "IX_TokenMetadata_Name_Description_Ticker");
+ }
+ }
+}
diff --git a/src/COMP.Data/Migrations/MetadataDbContextModelSnapshot.cs b/src/COMP.Data/Migrations/MetadataDbContextModelSnapshot.cs
index 63d8a0e..1cf2353 100644
--- a/src/COMP.Data/Migrations/MetadataDbContextModelSnapshot.cs
+++ b/src/COMP.Data/Migrations/MetadataDbContextModelSnapshot.cs
@@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("public")
- .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -44,7 +44,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("ReducerStates", "public");
});
- modelBuilder.Entity("COMP.Data.Models.Entity.SyncState", b =>
+ modelBuilder.Entity("COMP.Data.Models.Entity.RegistrySyncState", b =>
{
b.Property("Hash")
.HasColumnType("text");
@@ -54,88 +54,88 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasKey("Hash");
- b.ToTable("SyncState", "public");
+ b.ToTable("RegistrySyncState", "public");
});
- modelBuilder.Entity("COMP.Data.Models.Entity.TokenMetadata", b =>
+ modelBuilder.Entity("COMP.Data.Models.Entity.TokenMetadataOnChain", b =>
{
b.Property("Subject")
.HasColumnType("text");
+ b.Property("AssetName")
+ .IsRequired()
+ .HasColumnType("text");
+
b.Property("Decimals")
.HasColumnType("integer");
b.Property("Description")
+ .IsRequired()
.HasColumnType("text");
b.Property("Logo")
+ .IsRequired()
.HasColumnType("text");
b.Property("Name")
.IsRequired()
.HasColumnType("text");
- b.Property("Policy")
- .HasColumnType("text");
-
b.Property("PolicyId")
.IsRequired()
.HasColumnType("text");
- b.Property("Ticker")
- .IsRequired()
- .HasColumnType("text");
+ b.Property("Quantity")
+ .HasColumnType("bigint");
- b.Property("Url")
- .HasColumnType("text");
+ b.Property("TokenType")
+ .HasColumnType("integer");
b.HasKey("Subject");
- b.HasIndex("Name", "Description", "Ticker")
- .HasDatabaseName("IX_TokenMetadata_Name_Description_Ticker");
+ b.HasIndex("PolicyId");
- b.ToTable("TokenMetadata", "public");
+ b.ToTable("TokenMetadataOnChain", "public");
});
- modelBuilder.Entity("COMP.Data.Models.Entity.TokenMetadataOnChain", b =>
+ modelBuilder.Entity("COMP.Data.Models.Entity.TokenMetadataRegistry", b =>
{
b.Property("Subject")
.HasColumnType("text");
- b.Property("AssetName")
- .IsRequired()
- .HasColumnType("text");
-
b.Property("Decimals")
.HasColumnType("integer");
b.Property("Description")
- .IsRequired()
.HasColumnType("text");
b.Property("Logo")
- .IsRequired()
.HasColumnType("text");
b.Property("Name")
.IsRequired()
.HasColumnType("text");
+ b.Property("Policy")
+ .HasColumnType("text");
+
b.Property("PolicyId")
.IsRequired()
.HasColumnType("text");
- b.Property("Quantity")
- .HasColumnType("bigint");
+ b.Property("Ticker")
+ .IsRequired()
+ .HasColumnType("text");
- b.Property("TokenType")
- .HasColumnType("integer");
+ b.Property("Url")
+ .HasColumnType("text");
b.HasKey("Subject");
- b.HasIndex("PolicyId");
+ b.HasIndex("Name", "Description", "Ticker")
+ .HasDatabaseName("IX_TokenMetadataRegistry_Name_Description_Ticker");
- b.ToTable("TokenMetadataOnChain", "public");
+ b.ToTable("TokenMetadataRegistry", "public");
});
#pragma warning restore 612, 618
}
diff --git a/src/COMP.Data/Models/Entity/SyncState.cs b/src/COMP.Data/Models/Entity/RegistrySyncState.cs
similarity index 70%
rename from src/COMP.Data/Models/Entity/SyncState.cs
rename to src/COMP.Data/Models/Entity/RegistrySyncState.cs
index 8aec3b7..51cb4b5 100644
--- a/src/COMP.Data/Models/Entity/SyncState.cs
+++ b/src/COMP.Data/Models/Entity/RegistrySyncState.cs
@@ -1,6 +1,6 @@
namespace COMP.Data.Models.Entity;
-public record SyncState(
+public record RegistrySyncState(
string Hash,
DateTimeOffset Date
);
diff --git a/src/COMP.Data/Models/Entity/TokenMetadata.cs b/src/COMP.Data/Models/Entity/TokenMetadataRegistry.cs
similarity index 85%
rename from src/COMP.Data/Models/Entity/TokenMetadata.cs
rename to src/COMP.Data/Models/Entity/TokenMetadataRegistry.cs
index 5e17172..eed50b2 100644
--- a/src/COMP.Data/Models/Entity/TokenMetadata.cs
+++ b/src/COMP.Data/Models/Entity/TokenMetadataRegistry.cs
@@ -1,6 +1,6 @@
namespace COMP.Data.Models.Entity;
-public record TokenMetadata(
+public record TokenMetadataRegistry(
string Subject,
string Name,
string Ticker,
diff --git a/src/COMP.Sync/COMP.Sync.csproj b/src/COMP.Sync/COMP.Sync.csproj
index 0c5a3ce..73175ff 100644
--- a/src/COMP.Sync/COMP.Sync.csproj
+++ b/src/COMP.Sync/COMP.Sync.csproj
@@ -8,13 +8,14 @@
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
-
- contentfiles
-
diff --git a/src/COMP.Sync/Program.cs b/src/COMP.Sync/Program.cs
index 4dba79e..71cce5e 100644
--- a/src/COMP.Sync/Program.cs
+++ b/src/COMP.Sync/Program.cs
@@ -3,7 +3,6 @@
using Microsoft.EntityFrameworkCore;
using COMP.Data.Data;
using COMP.Sync.Services;
-using COMP.API.Modules.Handlers;
using COMP.Sync.Reducers;
using System.Net.Http.Headers;
using System.Reflection;
@@ -15,7 +14,6 @@
// Register reducers - the assembly scanning will find CIP25Reducer
builder.Services.AddReducers(builder.Configuration);
-builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
diff --git a/src/COMP.Sync/Reducers/CIP25Reducer.cs b/src/COMP.Sync/Reducers/CIP25Reducer.cs
index 7083388..6c2fc19 100644
--- a/src/COMP.Sync/Reducers/CIP25Reducer.cs
+++ b/src/COMP.Sync/Reducers/CIP25Reducer.cs
@@ -20,9 +20,12 @@ ILogger logger
private static readonly string[] ValidSchemes = ["https://", "http://", "ipfs://", "ar://", "data:"];
private static readonly Regex DataUriRegex = MyRegex();
+ // Rollback is intentionally not implemented - this metadata indexer is append-only
+ // and doesn't track historical state. On-chain metadata updates are rare and
+ // rollbacks would require re-syncing from scratch.
public async Task RollBackwardAsync(ulong slot)
{
- logger.LogWarning("Rollback requested to slot {Slot}. Manual resync may be required as we don't maintain historical state.", slot);
+ logger.LogWarning("Rollback requested to slot {Slot}. Manual resync may be required.", slot);
await Task.CompletedTask;
}
@@ -31,78 +34,61 @@ public async Task RollForwardAsync(Block block)
List txBodies = [.. block.TransactionBodies()];
Dictionary auxiliaryDataDict = block.AuxiliaryDataSet().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
- Dictionary tokensWithMetadata = [];
-
- for (int i = 0; i < txBodies.Count; i++)
- {
- TransactionBody tx = txBodies[i];
- Dictionary? mint = tx.Mint();
- if (mint == null || mint.Count == 0)
- continue;
-
- if (!auxiliaryDataDict.TryGetValue(i, out AuxiliaryData? auxData))
- continue;
-
- Metadata? metadata = auxData.Metadata();
- if (metadata == null)
- continue;
-
- if (!metadata.Value().TryGetValue(721, out TransactionMetadatum? cip25Value))
- continue;
-
- if (cip25Value is not MetadatumMap rootMap)
- continue;
-
- foreach (KeyValuePair policyEntry in mint)
+ // Extract all potential tokens with CIP-25 metadata
+ List allTokens = [.. txBodies
+ .Select((tx, index) => (tx, index))
+ .Where(x => x.tx.Mint() is { Count: > 0 })
+ .Where(x => auxiliaryDataDict.TryGetValue(x.index, out AuxiliaryData? aux)
+ && aux.Metadata()?.Value().TryGetValue(721, out TransactionMetadatum? cip25) == true
+ && cip25 is MetadatumMap)
+ .SelectMany(x =>
{
- string policyId = Convert.ToHexString(policyEntry.Key).ToLowerInvariant();
-
- foreach (KeyValuePair assetEntry in policyEntry.Value.Value.Where(a => a.Value > 0))
+ MetadatumMap rootMap = (MetadatumMap)auxiliaryDataDict[x.index].Metadata()!.Value()[721];
+ return x.tx.Mint()!.SelectMany(policyEntry =>
{
- string assetNameHex = Convert.ToHexString(assetEntry.Key).ToLowerInvariant();
- string subject = $"{policyId}{assetNameHex}";
-
- TokenMetadataOnChain? tokenMetadata = ExtractMetadata(rootMap, policyId, assetNameHex, assetEntry.Value);
-
- if (tokenMetadata == null)
- continue;
-
- if (string.IsNullOrEmpty(tokenMetadata.Name) || string.IsNullOrEmpty(tokenMetadata.Logo))
- {
- logger.LogWarning("Skipping {Subject} - CIP-25 requires name and image fields", subject);
- continue;
- }
-
- tokensWithMetadata[subject] = tokenMetadata;
- }
- }
- }
+ string policyId = Convert.ToHexString(policyEntry.Key).ToLowerInvariant();
+ return policyEntry.Value.Value
+ .Where(a => a.Value > 0)
+ .Select(assetEntry =>
+ {
+ string assetNameHex = Convert.ToHexString(assetEntry.Key).ToLowerInvariant();
+ return ExtractMetadata(rootMap, policyId, assetNameHex, assetEntry.Value);
+ });
+ });
+ })];
+
+ // Log skipped tokens
+ allTokens
+ .Where(t => t is not null && (string.IsNullOrEmpty(t.Name) || string.IsNullOrEmpty(t.Logo)))
+ .ToList()
+ .ForEach(t => logger.LogWarning("Skipping {Subject} - CIP-25 requires name and image fields", t!.Subject));
+
+ // Filter valid tokens
+ Dictionary tokensWithMetadata = allTokens
+ .Where(t => t is not null && !string.IsNullOrEmpty(t.Name) && !string.IsNullOrEmpty(t.Logo))
+ .GroupBy(t => t!.Subject)
+ .ToDictionary(g => g.Key, g => g.Last()!);
if (tokensWithMetadata.Count == 0)
return;
await using MetadataDbContext db = await dbContextFactory.CreateDbContextAsync();
- List existingSubjects = [.. tokensWithMetadata.Keys];
Dictionary existingRecords = await db.TokenMetadataOnChain
- .Where(t => existingSubjects.Contains(t.Subject))
+ .Where(t => tokensWithMetadata.Keys.Contains(t.Subject))
.AsNoTracking()
.ToDictionaryAsync(t => t.Subject);
- foreach ((string? subject, TokenMetadataOnChain? tokenData) in tokensWithMetadata)
- {
- if (existingRecords.TryGetValue(subject, out TokenMetadataOnChain? existing))
- {
- db.TokenMetadataOnChain.Update(tokenData);
- logger.LogInformation("Updated {Subject} - quantity: {OldQty} -> {NewQty}",
- subject, existing.Quantity, tokenData.Quantity);
- }
- else
- {
- db.TokenMetadataOnChain.Add(tokenData);
- logger.LogInformation("Inserted {Subject} with quantity: {Quantity}", subject, tokenData.Quantity);
- }
- }
+ List toInsert = [.. tokensWithMetadata.Values.Where(t => !existingRecords.ContainsKey(t.Subject))];
+ List toUpdate = [.. tokensWithMetadata.Values.Where(t => existingRecords.TryGetValue(t.Subject, out TokenMetadataOnChain? existing) && !TokensAreEqual(t, existing))];
+
+ db.TokenMetadataOnChain.AddRange(toInsert);
+ db.TokenMetadataOnChain.UpdateRange(toUpdate);
+
+ toInsert.ForEach(t => logger.LogInformation("Inserted {Subject} with quantity: {Quantity}", t.Subject, t.Quantity));
+ toUpdate.ForEach(t => logger.LogInformation("Updated {Subject} - quantity: {OldQty} -> {NewQty}",
+ t.Subject, existingRecords[t.Subject].Quantity, t.Quantity));
+
await db.SaveChangesAsync();
}
@@ -157,45 +143,36 @@ public async Task RollForwardAsync(Block block)
if (assetKvp.Value is not MetadatumMap assetMap)
return null;
- TokenMetadataOnChain metadata = new(
+ Dictionary fields = assetMap.Value
+ .Where(f => f.Key is MetadataText)
+ .ToDictionary(f => ((MetadataText)f.Key).Value ?? string.Empty, f => f.Value);
+
+ string name = fields.TryGetValue("name", out TransactionMetadatum? n) && n is MetadataText nt
+ ? nt.Value ?? string.Empty : string.Empty;
+
+ string imageUri = fields.TryGetValue("image", out TransactionMetadatum? img)
+ ? img switch
+ {
+ MetadataText imageText => imageText.Value ?? string.Empty,
+ MetadatumList imageList => string.Join(string.Empty, imageList.Value.OfType().Select(t => t.Value)),
+ _ => string.Empty
+ }
+ : string.Empty;
+
+ string description = fields.TryGetValue("description", out TransactionMetadatum? d) && d is MetadataText dt
+ ? dt.Value ?? string.Empty : string.Empty;
+
+ return new TokenMetadataOnChain(
Subject: $"{policyId}{assetNameHex}",
PolicyId: policyId,
AssetName: assetNameHex,
- Name: "",
- Logo: "",
- Description: "",
+ Name: name,
+ Logo: IsValidUri(imageUri) ? imageUri : string.Empty,
+ Description: description,
Quantity: quantity,
Decimals: 0,
TokenType: TokenType.CIP25
);
-
- foreach (KeyValuePair field in assetMap.Value)
- {
- if (field.Key is MetadataText keyText)
- {
- switch (keyText.Value)
- {
- case "name" when field.Value is MetadataText nameText:
- metadata = metadata with { Name = nameText.Value ?? "" };
- break;
- case "image":
- string imageUri = field.Value switch
- {
- MetadataText imageText => imageText.Value ?? "",
- MetadatumList imageList => string.Join("", imageList.Value.OfType().Select(t => t.Value)),
- _ => ""
- };
- if (IsValidUri(imageUri))
- metadata = metadata with { Logo = imageUri };
- break;
- case "description" when field.Value is MetadataText descText:
- metadata = metadata with { Description = descText.Value ?? "" };
- break;
- }
- }
- }
-
- return metadata;
}
private static bool IsValidUri(string uri)
@@ -216,6 +193,13 @@ private static bool IsValidUri(string uri)
return uri.Length > ValidSchemes.First(s => uri.StartsWith(s, StringComparison.OrdinalIgnoreCase)).Length;
}
+ private static bool TokensAreEqual(TokenMetadataOnChain a, TokenMetadataOnChain b) =>
+ a.Name == b.Name &&
+ a.Logo == b.Logo &&
+ a.Description == b.Description &&
+ a.Quantity == b.Quantity &&
+ a.Decimals == b.Decimals;
+
[GeneratedRegex(@"^data:image\/[a-zA-Z0-9]+(?:\+[a-zA-Z0-9]+)?;base64,", RegexOptions.Compiled)]
private static partial Regex MyRegex();
}
\ No newline at end of file
diff --git a/src/COMP.Sync/Reducers/CIP68Reducer.cs b/src/COMP.Sync/Reducers/CIP68Reducer.cs
index 3ccbd87..0c351de 100644
--- a/src/COMP.Sync/Reducers/CIP68Reducer.cs
+++ b/src/COMP.Sync/Reducers/CIP68Reducer.cs
@@ -30,10 +30,10 @@ ILogger logger
{ "RFT", "001bc280" }
};
- public async Task RollBackwardAsync(ulong slot)
- {
- await Task.CompletedTask;
- }
+ // Rollback is intentionally not implemented - this metadata indexer is append-only
+ // and doesn't track historical state. On-chain metadata updates are rare and
+ // rollbacks would require re-syncing from scratch.
+ public async Task RollBackwardAsync(ulong slot) => await Task.CompletedTask;
public async Task RollForwardAsync(Block block)
{
@@ -52,244 +52,155 @@ public async Task RollForwardAsync(Block block)
private static Dictionary ExtractAllReferenceTokens(
List txBodies,
List witnessSets,
- ILogger logger)
- {
- Dictionary referenceTokens = [];
-
- for (int i = 0; i < txBodies.Count; i++)
- {
- List? outputs = txBodies[i].Outputs()?.ToList();
- if (outputs == null || outputs.Count == 0)
- continue;
-
- TransactionWitnessSet? witnessSet = i < witnessSets.Count ? witnessSets[i] : null;
- ExtractReferenceTokensFromOutputs(outputs, referenceTokens, witnessSet, logger);
- }
-
- return referenceTokens;
- }
-
- private static void ExtractReferenceTokensFromOutputs(
- List outputs,
- Dictionary referenceTokens,
- TransactionWitnessSet? witnessSet,
- ILogger logger)
- {
- foreach (TransactionOutput output in outputs)
- {
- Dictionary? multiAsset = output.Amount()?.MultiAsset();
- if (multiAsset == null)
- continue;
-
- foreach (KeyValuePair policy in multiAsset)
+ ILogger logger) =>
+ txBodies
+ .Select((tx, i) => (outputs: tx.Outputs()?.ToList(), witnessSet: i < witnessSets.Count ? witnessSets[i] : null))
+ .Where(x => x.outputs is { Count: > 0 })
+ .SelectMany(x => x.outputs!.SelectMany(output =>
{
- string policyId = Convert.ToHexString(policy.Key).ToLowerInvariant();
- ProcessPolicyAssets(policy.Value.Value, policyId, output, referenceTokens, witnessSet, logger);
- }
- }
- }
-
- private static void ProcessPolicyAssets(
- Dictionary assets,
- string policyId,
- TransactionOutput output,
- Dictionary referenceTokens,
- TransactionWitnessSet? witnessSet,
- ILogger logger)
- {
- foreach (KeyValuePair asset in assets)
- {
- if (asset.Value == 0)
- continue;
-
- string assetNameHex = Convert.ToHexString(asset.Key).ToLowerInvariant();
- if (!assetNameHex.StartsWith(REFERENCE_PREFIX))
- continue;
+ Dictionary? multiAsset = output.Amount()?.MultiAsset();
+ if (multiAsset is null) return [];
- string baseName = assetNameHex[8..];
- string referenceSubject = $"{policyId}{assetNameHex}";
-
- if (referenceTokens.ContainsKey(referenceSubject))
- continue;
-
- byte[]? datumBytes = ExtractDatum(output, witnessSet);
- if (datumBytes == null)
- {
- logger.LogDebug("No datum found for reference token {Subject}", referenceSubject);
- continue;
- }
-
- referenceTokens[referenceSubject] = (policyId, baseName, datumBytes);
- }
- }
+ return multiAsset.SelectMany(policy =>
+ {
+ string policyId = Convert.ToHexString(policy.Key).ToLowerInvariant();
+ return policy.Value.Value
+ .Where(asset => asset.Value > 0)
+ .Select(asset => Convert.ToHexString(asset.Key).ToLowerInvariant())
+ .Where(assetNameHex => assetNameHex.StartsWith(REFERENCE_PREFIX))
+ .Select(assetNameHex =>
+ {
+ byte[]? datumBytes = ExtractDatum(output, x.witnessSet);
+ if (datumBytes is null)
+ {
+ logger.LogDebug("No datum found for reference token {Subject}", $"{policyId}{assetNameHex}");
+ return ((string policyId, string baseName, byte[] datumBytes)?)null;
+ }
+ return (policyId, baseName: assetNameHex[8..], datumBytes);
+ })
+ .Where(r => r is not null)
+ .Select(r => r!.Value);
+ });
+ }))
+ .GroupBy(r => $"{r.policyId}{REFERENCE_PREFIX}{r.baseName}")
+ .ToDictionary(g => g.Key, g => g.First());
private async Task> ProcessReferenceTokensAsync(
Dictionary referenceTokens,
List txBodies)
{
Dictionary mintedUserTokens = ExtractMintedUserTokens(txBodies);
- Dictionary tokensToProcess = [];
- foreach ((string _, (string? policyId, string? baseName, byte[]? datumBytes)) in referenceTokens)
- {
- string? userTokenSubject = await DetermineUserTokenSubjectAsync(baseName, policyId, mintedUserTokens);
- if (userTokenSubject == null)
- continue;
+ // Build all potential user token subjects for single batch DB query
+ List allPotentialSubjects = referenceTokens.Values
+ .SelectMany(r => UserTokenPrefixes.Values.Select(prefix => $"{r.policyId}{prefix}{r.baseName}"))
+ .ToList();
- long quantity = await GetTokenQuantityAsync(userTokenSubject, mintedUserTokens);
- string assetName = userTokenSubject[policyId.Length..];
- (string? name, string? image, string? description, int? decimals) = ExtractCIP68Metadata(datumBytes);
+ // Single DB query to get all existing tokens
+ await using MetadataDbContext db = await dbContextFactory.CreateDbContextAsync();
+ Dictionary existingTokens = await db.TokenMetadataOnChain
+ .Where(t => allPotentialSubjects.Contains(t.Subject))
+ .AsNoTracking()
+ .ToDictionaryAsync(t => t.Subject);
- if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(image))
+ return referenceTokens.Values
+ .Select(r =>
{
- logger.LogWarning("Skipping {Subject} - CIP-68 requires name and image fields", userTokenSubject);
- continue;
- }
-
- tokensToProcess[userTokenSubject] = new TokenMetadataOnChain(
- Subject: userTokenSubject,
- PolicyId: policyId,
- AssetName: assetName,
- Name: name,
- Logo: image,
- Description: description,
- Quantity: quantity,
- Decimals: decimals ?? 0,
- TokenType: TokenType.CIP68
- );
-
- logger.LogDebug("Processed CIP-68 token: {Subject} (qty: {Quantity})", userTokenSubject, quantity);
- }
+ // Determine user token subject in prefix priority order
+ string? userTokenSubject = UserTokenPrefixes.Values
+ .Select(prefix => $"{r.policyId}{prefix}{r.baseName}")
+ .FirstOrDefault(s => mintedUserTokens.ContainsKey(s) || existingTokens.ContainsKey(s));
- return tokensToProcess;
- }
+ if (userTokenSubject == null)
+ return null;
- private static Dictionary ExtractMintedUserTokens(List txBodies)
- {
- Dictionary mintedUserTokens = [];
+ // Get quantity from minted or existing
+ long quantity = mintedUserTokens.GetValueOrDefault(userTokenSubject, 0);
+ if (quantity == 0)
+ quantity = existingTokens.GetValueOrDefault(userTokenSubject)?.Quantity ?? 0;
- foreach (TransactionBody tx in txBodies)
- {
- Dictionary? mint = tx.Mint();
- if (mint == null)
- continue;
+ (string name, string image, string description, int? decimals) = ExtractCIP68Metadata(r.datumBytes);
- foreach (KeyValuePair policyEntry in mint)
- {
- string policyId = Convert.ToHexString(policyEntry.Key).ToLowerInvariant();
- foreach (KeyValuePair asset in policyEntry.Value.Value)
+ if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(image))
{
- if (asset.Value <= 0)
- continue;
-
- string assetNameHex = Convert.ToHexString(asset.Key).ToLowerInvariant();
- if (UserTokenPrefixes.Values.Any(assetNameHex.StartsWith))
- {
- string subject = $"{policyId}{assetNameHex}";
- mintedUserTokens[subject] = asset.Value;
- }
+ logger.LogWarning("Skipping {Subject} - CIP-68 requires name and image fields", userTokenSubject);
+ return null;
}
- }
- }
- return mintedUserTokens;
+ logger.LogDebug("Processed CIP-68 token: {Subject} (qty: {Quantity})", userTokenSubject, quantity);
+
+ return new TokenMetadataOnChain(
+ Subject: userTokenSubject,
+ PolicyId: r.policyId,
+ AssetName: userTokenSubject[r.policyId.Length..],
+ Name: name,
+ Logo: image,
+ Description: description,
+ Quantity: quantity,
+ Decimals: decimals ?? 0,
+ TokenType: TokenType.CIP68
+ );
+ })
+ .Where(t => t is not null)
+ .ToDictionary(t => t!.Subject, t => t!);
}
- private async Task GetTokenQuantityAsync(string userTokenSubject, Dictionary mintedUserTokens)
- {
- long quantity = mintedUserTokens.GetValueOrDefault(userTokenSubject, 0);
- if (quantity > 0)
- return quantity;
-
- await using MetadataDbContext db = await dbContextFactory.CreateDbContextAsync();
- TokenMetadataOnChain? existing = await db.TokenMetadataOnChain
- .AsNoTracking()
- .FirstOrDefaultAsync(t => t.Subject == userTokenSubject);
- return existing?.Quantity ?? 0;
- }
-
- private async Task DetermineUserTokenSubjectAsync(
- string baseName,
- string policyId,
- Dictionary mintedUserTokens)
+ private static Dictionary ExtractMintedUserTokens(List txBodies) =>
+ txBodies
+ .Select(tx => tx.Mint())
+ .Where(mint => mint is not null)
+ .SelectMany(mint => mint!)
+ .SelectMany(policyEntry =>
+ {
+ string policyId = Convert.ToHexString(policyEntry.Key).ToLowerInvariant();
+ return policyEntry.Value.Value
+ .Where(asset => asset.Value > 0)
+ .Select(asset => (
+ subject: $"{policyId}{Convert.ToHexString(asset.Key).ToLowerInvariant()}",
+ quantity: asset.Value
+ ))
+ .Where(x => UserTokenPrefixes.Values.Any(x.subject[(policyId.Length)..].StartsWith));
+ })
+ .GroupBy(x => x.subject)
+ .ToDictionary(g => g.Key, g => g.Sum(x => x.quantity));
+
+ private static (string name, string image, string description, int? decimals) ExtractCIP68Metadata(byte[] datumBytes)
{
- foreach ((string _, string? prefix) in UserTokenPrefixes)
+ try
{
- string subject = $"{policyId}{prefix}{baseName}";
- if (mintedUserTokens.ContainsKey(subject))
- return subject;
- }
+ Cip68 datum = CborSerializer.Deserialize>(datumBytes);
+ if (datum?.Metadata is not PlutusMap map)
+ return (string.Empty, string.Empty, string.Empty, null);
- await using MetadataDbContext db = await dbContextFactory.CreateDbContextAsync();
- foreach ((string _, string? prefix) in UserTokenPrefixes)
- {
- string subject = $"{policyId}{prefix}{baseName}";
- bool exists = await db.TokenMetadataOnChain.AnyAsync(t => t.Subject == subject);
- if (exists)
- return subject;
- }
+ Dictionary fields = map.PlutusData
+ .Where(kvp => kvp.Key is PlutusBoundedBytes)
+ .ToDictionary(
+ kvp => System.Text.Encoding.UTF8.GetString(((PlutusBoundedBytes)kvp.Key).Value),
+ kvp => kvp.Value);
- return null;
- }
+ string name = fields.TryGetValue("name", out PlutusData? n) && n is PlutusBoundedBytes nb
+ ? System.Text.Encoding.UTF8.GetString(nb.Value) : string.Empty;
- private (string name, string image, string description, int? decimals) ExtractCIP68Metadata(byte[] datumBytes)
- {
- try
- {
- Cip68 datum = CborSerializer.Deserialize>(datumBytes);
- if (datum?.Metadata == null)
- return ("", "", "", null);
+ string image = fields.TryGetValue("image", out PlutusData? i) && i is PlutusBoundedBytes ib
+ ? System.Text.Encoding.UTF8.GetString(ib.Value) : string.Empty;
- string? name = null, image = null, description = null;
- int? decimals = null;
+ string description = fields.TryGetValue("description", out PlutusData? d) && d is PlutusBoundedBytes db
+ ? System.Text.Encoding.UTF8.GetString(db.Value) : string.Empty;
- if (datum.Metadata is PlutusMap map)
- {
- foreach (KeyValuePair kvp in map.PlutusData)
+ int? decimals = fields.TryGetValue("decimals", out PlutusData? dec)
+ ? dec switch
{
- if (kvp.Key is PlutusBoundedBytes keyBytes)
- {
- string key = System.Text.Encoding.UTF8.GetString(keyBytes.Value);
-
- switch (key)
- {
- case "name":
- if (kvp.Value is PlutusBoundedBytes nameBytes)
- {
- name = System.Text.Encoding.UTF8.GetString(nameBytes.Value);
- }
- break;
- case "image":
- if (kvp.Value is PlutusBoundedBytes imageBytes)
- {
- image = System.Text.Encoding.UTF8.GetString(imageBytes.Value);
- }
- break;
- case "description":
- if (kvp.Value is PlutusBoundedBytes descBytes)
- {
- description = System.Text.Encoding.UTF8.GetString(descBytes.Value);
- }
- break;
- case "decimals":
- if (kvp.Value is PlutusInt64 decInt)
- {
- decimals = (int)decInt.Value;
- }
- else if (kvp.Value is PlutusUint64 decUint)
- {
- decimals = (int)decUint.Value;
- }
- break;
- }
- }
+ PlutusInt64 decInt => (int)decInt.Value,
+ PlutusUint64 decUint => (int)decUint.Value,
+ _ => null
}
- }
- return (name ?? "", image ?? "", description ?? "", decimals);
+ : null;
+
+ return (name, image, description, decimals);
}
catch
{
- return ("", "", "", null);
+ return (string.Empty, string.Empty, string.Empty, null);
}
}
@@ -318,47 +229,42 @@ private async Task GetTokenQuantityAsync(string userTokenSubject, Dictiona
return ResolveDatumFromWitnessSet(datumHash, plutusDataSet);
}
- private static byte[]? ResolveDatumFromWitnessSet(byte[] datumHash, IEnumerable plutusDataSet)
- {
- foreach (PlutusData plutusData in plutusDataSet)
- {
- byte[] datumBytes = plutusData.Raw();
- byte[] calculatedHash = HashUtil.Blake2b256(datumBytes);
-
- if (calculatedHash.SequenceEqual(datumHash))
- {
- return datumBytes;
- }
- }
- return null;
- }
+ private static byte[]? ResolveDatumFromWitnessSet(byte[] datumHash, IEnumerable plutusDataSet) =>
+ plutusDataSet
+ .Select(pd => pd.Raw())
+ .FirstOrDefault(datumBytes => HashUtil.Blake2b256(datumBytes).SequenceEqual(datumHash));
private async Task SaveTokensAsync(Dictionary tokensToProcess)
{
await using MetadataDbContext db = await dbContextFactory.CreateDbContextAsync();
- List subjects = [.. tokensToProcess.Keys];
- Dictionary existingRecords = await db.TokenMetadataOnChain
- .Where(t => subjects.Contains(t.Subject))
+ Dictionary existingTokens = await db.TokenMetadataOnChain
+ .Where(t => tokensToProcess.Keys.Contains(t.Subject))
.AsNoTracking()
.ToDictionaryAsync(t => t.Subject);
- foreach ((string? subject, TokenMetadataOnChain? tokenData) in tokensToProcess)
- {
- if (existingRecords.ContainsKey(subject))
- {
- db.TokenMetadataOnChain.Update(tokenData);
- logger.LogInformation("Updated CIP-68 token {Subject} with quantity {Quantity}",
- subject, tokenData.Quantity);
- }
- else
- {
- db.TokenMetadataOnChain.Add(tokenData);
- logger.LogInformation("Inserted CIP-68 token {Subject} with quantity {Quantity}",
- subject, tokenData.Quantity);
- }
- }
+ List toInsert = tokensToProcess.Values
+ .Where(t => !existingTokens.ContainsKey(t.Subject))
+ .ToList();
+
+ List toUpdate = tokensToProcess.Values
+ .Where(t => existingTokens.TryGetValue(t.Subject, out TokenMetadataOnChain? existing) && !TokensAreEqual(t, existing))
+ .ToList();
+
+ db.TokenMetadataOnChain.AddRange(toInsert);
+ db.TokenMetadataOnChain.UpdateRange(toUpdate);
+
+ toInsert.ForEach(t => logger.LogInformation("Inserted CIP-68 token {Subject} with quantity {Quantity}", t.Subject, t.Quantity));
+ toUpdate.ForEach(t => logger.LogInformation("Updated CIP-68 token {Subject} - quantity: {OldQty} -> {NewQty}",
+ t.Subject, existingTokens[t.Subject].Quantity, t.Quantity));
await db.SaveChangesAsync();
}
-}
\ No newline at end of file
+
+ private static bool TokensAreEqual(TokenMetadataOnChain a, TokenMetadataOnChain b) =>
+ a.Name == b.Name &&
+ a.Logo == b.Logo &&
+ a.Description == b.Description &&
+ a.Quantity == b.Quantity &&
+ a.Decimals == b.Decimals;
+}
diff --git a/src/COMP.Sync/Reducers/GithubReducer.cs b/src/COMP.Sync/Reducers/GithubReducer.cs
index c6c82d1..441cfcd 100644
--- a/src/COMP.Sync/Reducers/GithubReducer.cs
+++ b/src/COMP.Sync/Reducers/GithubReducer.cs
@@ -19,7 +19,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Starting metadata synchronization cycle");
- SyncState? syncState = await metadataDbService.GetSyncStateAsync(stoppingToken);
+ RegistrySyncState? syncState = await metadataDbService.GetRegistrySyncStateAsync(stoppingToken);
if (syncState is null)
{
@@ -38,28 +38,44 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
logger.LogError("Tree response is null");
break;
}
- foreach (GitTreeItem item in treeResponse.Tree)
- {
- if (item.Path?.StartsWith("mappings/") == true && item.Path.EndsWith(".json"))
- {
- string subject = ExtractSubjectFromPath(item.Path);
+ // Filter mapping files and extract subjects in single pass
+ List<(GitTreeItem item, string subject)> mappingFiles = [.. treeResponse.Tree
+ .Where(item => item.Path?.StartsWith("mappings/") == true && item.Path.EndsWith(".json"))
+ .Select(item => (item, subject: ExtractSubjectFromPath(item.Path)))];
- bool exist = await metadataDbService.SubjectExistsAsync(subject, stoppingToken);
- if (exist) continue;
+ // Batch check existing subjects to avoid N+1 queries
+ HashSet existingSubjects = await metadataDbService.GetExistingSubjectsAsync(
+ mappingFiles.Select(m => m.subject), stoppingToken);
- MetadataResponse? mapping = await githubService.GetMappingJsonAsync(latestCommit.Sha, item.Path, stoppingToken);
- TokenMetadata? token = MapTokenMetadata(mapping);
+ foreach ((GitTreeItem item, string subject) in mappingFiles)
+ {
+ if (existingSubjects.Contains(subject)) continue;
+ try
+ {
+ MetadataResponse? mapping = await githubService.GetMappingJsonAsync(latestCommit.Sha, item.Path!, stoppingToken);
+ TokenMetadataRegistry? token = MapTokenMetadataRegistry(mapping);
if (token == null) continue;
await metadataDbService.AddTokenAsync(token, stoppingToken);
}
+ catch (HttpRequestException httpEx)
+ {
+ logger.LogError(httpEx, "Network error while fetching metadata for subject {Subject}. Skipping this update.", subject);
+ }
+ catch (JsonException jsonEx)
+ {
+ logger.LogError(jsonEx, "JSON parsing error for subject {Subject}. File may be malformed. Skipping this update.", subject);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error processing metadata for subject {Subject}. Skipping this update.", subject);
+ }
}
- await metadataDbService.UpsertSyncStateAsync(latestCommit, stoppingToken);
+ await metadataDbService.UpsertRegistrySyncStateAsync(latestCommit, stoppingToken);
}
else
{
List latestCommitsSince = await GetLatestCommitsSinceAsync(syncState.Date, stoppingToken);
-
foreach (GitCommit commit in latestCommitsSince)
{
@@ -68,52 +84,61 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
GitCommit? resolvedCommit = await githubService.GetMappingJsonAsync(commit.Url, cancellationToken: stoppingToken);
if (resolvedCommit is null || string.IsNullOrEmpty(resolvedCommit.Sha) || resolvedCommit.Files is null) continue;
- foreach (GitCommitFile file in resolvedCommit.Files)
+ // Filter and extract subjects for changed mapping files in single pass
+ List<(GitCommitFile file, string subject)> filesToProcess = [.. resolvedCommit.Files
+ .Where(f => f.Filename?.StartsWith("mappings/") == true
+ && f.Filename.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(f.Status, "removed", StringComparison.OrdinalIgnoreCase))
+ .Select(f => (file: f, subject: ExtractSubjectFromPath(f.Filename)))];
+
+ // Log removed mapping files (deletes are intentionally not applied locally)
+ foreach (GitCommitFile removedFile in resolvedCommit.Files.Where(f =>
+ f.Filename?.StartsWith("mappings/") == true
+ && f.Filename.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(f.Status, "removed", StringComparison.OrdinalIgnoreCase)))
{
- if (file.Filename is not null)
+ logger.LogInformation("Skipping removed file {Filename} in commit {Sha}", removedFile.Filename, resolvedCommit.Sha);
+ }
+
+ // Batch check existing subjects
+ HashSet existingSubjects = await metadataDbService.GetExistingSubjectsAsync(
+ filesToProcess.Select(f => f.subject), stoppingToken);
+
+ foreach ((GitCommitFile file, string subject) in filesToProcess)
+ {
+ try
{
- if (string.Equals(file.Status, "removed", StringComparison.OrdinalIgnoreCase))
+ MetadataResponse? mapping = await githubService.GetMappingJsonAsync(resolvedCommit.Sha, file.Filename!, stoppingToken);
+ TokenMetadataRegistry? token = MapTokenMetadataRegistry(mapping);
+ if (token is null)
{
- logger.LogInformation("Skipping removed file {Filename} in commit {Sha}", file.Filename, resolvedCommit.Sha);
+ logger.LogWarning("Failed to map registry item for subject {Subject} in commit {Sha}", subject, resolvedCommit.Sha);
continue;
}
- string subject = ExtractSubjectFromPath(file.Filename);
- try
+ if (existingSubjects.Contains(subject))
{
- MetadataResponse? mapping = await githubService.GetMappingJsonAsync(resolvedCommit.Sha, file.Filename, stoppingToken);
- TokenMetadata? token = MapTokenMetadata(mapping);
- if (token is null)
- {
- logger.LogWarning("Failed to map registry item for subject {Subject} in commit {Sha}", subject, resolvedCommit.Sha);
- continue;
- }
-
- bool exists = await metadataDbService.SubjectExistsAsync(subject, stoppingToken);
- if (exists)
- {
- await metadataDbService.UpdateTokenAsync(token, stoppingToken);
- }
- else
- {
- await metadataDbService.AddTokenAsync(token, stoppingToken);
- }
+ await metadataDbService.UpdateTokenAsync(token, stoppingToken);
}
- catch (HttpRequestException httpEx)
+ else
{
- logger.LogError(httpEx, "Network error while fetching metadata for subject {Subject}. Skipping this update.", subject);
- }
- catch (JsonException jsonEx)
- {
- logger.LogError(jsonEx, "JSON parsing error for subject {Subject}. File may be malformed. Skipping this update.", subject);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Unexpected error processing metadata for subject {Subject}. Skipping this update.", subject);
+ await metadataDbService.AddTokenAsync(token, stoppingToken);
}
}
+ catch (HttpRequestException httpEx)
+ {
+ logger.LogError(httpEx, "Network error while fetching metadata for subject {Subject}. Skipping this update.", subject);
+ }
+ catch (JsonException jsonEx)
+ {
+ logger.LogError(jsonEx, "JSON parsing error for subject {Subject}. File may be malformed. Skipping this update.", subject);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error processing metadata for subject {Subject}. Skipping this update.", subject);
+ }
}
- await metadataDbService.UpsertSyncStateAsync(resolvedCommit, stoppingToken);
+ await metadataDbService.UpsertRegistrySyncStateAsync(resolvedCommit, stoppingToken);
}
}
@@ -127,7 +152,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
}
- public TokenMetadata? MapTokenMetadata(MetadataResponse? resp)
+ public TokenMetadataRegistry? MapTokenMetadataRegistry(MetadataResponse? resp)
{
if (resp is null)
{
@@ -158,7 +183,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
return null;
}
- return new TokenMetadata(
+ return new TokenMetadataRegistry(
Subject: subject,
Name: name,
Ticker: ticker,
@@ -190,8 +215,17 @@ private static string ExtractSubjectFromPath(string? path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
-
- return path.Replace("mappings/", string.Empty)
- .Replace(".json", string.Empty);
+
+ const string mappingsPrefix = "mappings/";
+ if (!path.StartsWith(mappingsPrefix, StringComparison.Ordinal))
+ return string.Empty;
+
+ if (!path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ return string.Empty;
+
+ string relativePath = path[mappingsPrefix.Length..];
+ return relativePath.Length <= ".json".Length
+ ? string.Empty
+ : relativePath[..^".json".Length];
}
}
diff --git a/src/COMP.Sync/Services/MetadataDbService.cs b/src/COMP.Sync/Services/MetadataDbService.cs
index 855dace..f760420 100644
--- a/src/COMP.Sync/Services/MetadataDbService.cs
+++ b/src/COMP.Sync/Services/MetadataDbService.cs
@@ -10,7 +10,7 @@ public class MetadataDbService
ILogger logger,
IDbContextFactory _dbContextFactory)
{
- public async Task AddTokenAsync(TokenMetadata token, CancellationToken cancellationToken)
+ public async Task AddTokenAsync(TokenMetadataRegistry token, CancellationToken cancellationToken)
{
using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -23,20 +23,20 @@ public class MetadataDbService
return null;
}
- await dbContext.TokenMetadata.AddAsync(token, cancellationToken);
+ await dbContext.TokenMetadataRegistry.AddAsync(token, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return token;
}
- public async Task GetSyncStateAsync(CancellationToken cancellationToken)
+ public async Task GetRegistrySyncStateAsync(CancellationToken cancellationToken)
{
using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
- return await dbContext.SyncState
+ return await dbContext.RegistrySyncState
.OrderByDescending(ss => ss.Date)
.FirstOrDefaultAsync(cancellationToken);
}
- public async Task UpsertSyncStateAsync(GitCommit latestCommit, CancellationToken cancellationToken)
+ public async Task UpsertRegistrySyncStateAsync(GitCommit latestCommit, CancellationToken cancellationToken)
{
using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -44,61 +44,39 @@ public async Task UpsertSyncStateAsync(GitCommit latestCommit, CancellationToken
DateTimeOffset newDate = latestCommit.Commit?.Author?.Date ?? DateTimeOffset.UtcNow;
// Delete existing sync state if it exists
- SyncState? existingSyncState = await dbContext.SyncState
+ RegistrySyncState? existingRegistrySyncState = await dbContext.RegistrySyncState
.FirstOrDefaultAsync(cancellationToken);
- if (existingSyncState is not null)
+ if (existingRegistrySyncState is not null)
{
- string oldHash = existingSyncState.Hash;
- dbContext.SyncState.Remove(existingSyncState);
+ string oldHash = existingRegistrySyncState.Hash;
+ dbContext.RegistrySyncState.Remove(existingRegistrySyncState);
logger.LogInformation("Removed old sync state with hash: {OldHash}", oldHash);
}
// Insert new sync state
- SyncState syncState = new(
+ RegistrySyncState syncState = new(
Hash: newSha,
Date: newDate
);
- await dbContext.SyncState.AddAsync(syncState, cancellationToken);
+ await dbContext.RegistrySyncState.AddAsync(syncState, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("Sync state created with hash: {Hash}", newSha);
}
- public async Task SubjectExistsAsync(string subject, CancellationToken cancellationToken)
+ public async Task> GetExistingSubjectsAsync(IEnumerable subjects, CancellationToken cancellationToken)
{
using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
- return await dbContext.TokenMetadata
- .AnyAsync(t => t.Subject == subject, cancellationToken);
+ List subjectList = subjects.ToList();
+ List existing = await dbContext.TokenMetadataRegistry
+ .Where(t => subjectList.Contains(t.Subject))
+ .Select(t => t.Subject)
+ .ToListAsync(cancellationToken);
+ return [.. existing];
}
- public async Task DeleteTokenAsync(string subject, CancellationToken cancellationToken)
- {
- using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
-
- TokenMetadata? existingMetadata = await dbContext.TokenMetadata
- .Where(tm => tm.Subject == subject)
- .FirstOrDefaultAsync(cancellationToken);
- if (existingMetadata != null)
- {
- dbContext.TokenMetadata.Remove(existingMetadata);
- await dbContext.SaveChangesAsync(cancellationToken);
- }
- }
-
- public async Task UpdateTokenAsync(TokenMetadata updated, CancellationToken cancellationToken)
+ public async Task UpdateTokenAsync(TokenMetadataRegistry updated, CancellationToken cancellationToken)
{
- using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
-
- TokenMetadata? existingMetadata = await dbContext.TokenMetadata
- .AsNoTracking()
- .FirstOrDefaultAsync(t => t.Subject == updated.Subject, cancellationToken);
-
- if (existingMetadata is null)
- {
- logger.LogWarning("Token metadata not found for subject {Subject}", updated.Subject);
- return null;
- }
-
if (string.IsNullOrEmpty(updated.Subject) ||
string.IsNullOrEmpty(updated.Name) ||
string.IsNullOrEmpty(updated.Description) ||
@@ -108,9 +86,10 @@ public async Task DeleteTokenAsync(string subject, CancellationToken cancellatio
return null;
}
- // Preserve immutable fields from existing if needed; here we accept the provided updated entity
- dbContext.TokenMetadata.Update(updated);
+ using MetadataDbContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
+ dbContext.TokenMetadataRegistry.Update(updated);
await dbContext.SaveChangesAsync(cancellationToken);
return updated;
}
+
}
diff --git a/src/COMP.Sync/appsettings.example.json b/src/COMP.Sync/appsettings.example.json
new file mode 100644
index 0000000..0589304
--- /dev/null
+++ b/src/COMP.Sync/appsettings.example.json
@@ -0,0 +1,42 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "Comp.Sync.Reducers": "Debug"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "CardanoContext": "Host=localhost;Port=5432;Database=cardano_metadata;Username=postgres;Password=postgres",
+ "CardanoContextSchema": "public"
+ },
+ "CardanoNodeConnection": {
+ "ConnectionType": "UnixSocket",
+ "UnixSocket": {
+ "Path": "/path/to/cardano/node.socket"
+ },
+ "NetworkMagic": 764824073,
+ "MaxRollbackSlots": 1000,
+ "RollbackBuffer": 10,
+ "Slot": 0,
+ "Hash": ""
+ },
+ "Sync": {
+ "Dashboard": {
+ "TuiMode": false,
+ "RefreshInterval": 5000,
+ "DisplayType": "sync"
+ }
+ },
+ "GithubPAT": "YOUR_GITHUB_PAT",
+ "RegistryOwner": "cardano-foundation",
+ "RegistryRepo": "cardano-token-registry",
+ "Github": {
+ "UserAgent": {
+ "ProductName": "CardanoMetadataService",
+ "ProductUrl": "(+https://github.com/SAIB-Inc/COMP)"
+ }
+ }
+}