Skip to content
Open
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
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
</a>
<br>
<a href="https://dotnet.microsoft.com/download">
<img src="https://img.shields.io/badge/.NET-9.0-512BD4?style=flat-square" alt=".NET">
<img src="https://img.shields.io/badge/.NET-10.0-512BD4?style=flat-square" alt=".NET">
</a>
<a href="https://www.postgresql.org/">
<img src="https://img.shields.io/badge/PostgreSQL-15+-336791?style=flat-square" alt="PostgreSQL">
Expand All @@ -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:**

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -351,4 +372,4 @@ We welcome contributions! Please follow these steps:
<a href="https://github.com/SAIB-Inc/COMP/issues">Request Feature</a> •
<a href="https://docs.cardano.metadata.dev">Documentation</a>
</p>
</div>
</div>
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ services:
app:
build:
context: .
dockerfile: Dockerfile
dockerfile: infrastructure/comp-api/Dockerfile
container_name: comp-app
restart: unless-stopped
depends_on:
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/comp-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,4 +32,4 @@ ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080

# Run the application
ENTRYPOINT ["dotnet", "COMP.API.dll"]
ENTRYPOINT ["dotnet", "COMP.API.dll"]
1 change: 0 additions & 1 deletion infrastructure/comp-sync/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
10 changes: 6 additions & 4 deletions src/COMP.API/COMP.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>COMP.API</AssemblyName>
<RootNamespace>Comp</RootNamespace>
<RootNamespace>COMP.API</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FastEndpoints" Version="5.31.0" />
<PackageReference Include="FastEndpoints.Swagger" Version="5.31.0" />
<PackageReference Include="AWSSDK.S3" Version="4.0.18.1" />
<PackageReference Include="FastEndpoints" Version="7.2.0" />
<PackageReference Include="FastEndpoints.Swagger" Version="7.2.0" />
<PackageReference Include="Linqkit.Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.19" />
<PackageReference Include="Mime" Version="3.8.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.12.30" />
</ItemGroup>

<ItemGroup>
Expand Down
15 changes: 6 additions & 9 deletions src/COMP.API/Endpoints/BatchTokenMetadataEndpoint.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using FastEndpoints;
using COMP.API.Modules.Handlers;
using COMP.API.Handlers;
using COMP.Data.Models.Request;

namespace COMP.API.Endpoints;
Expand All @@ -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)
Expand All @@ -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);
}
}
13 changes: 4 additions & 9 deletions src/COMP.API/Endpoints/GetTokenMetadataEndpoint.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using FastEndpoints;
using COMP.API.Modules.Handlers;
using COMP.API.Handlers;
using COMP.Data.Models.Request;

namespace COMP.API.Endpoints;
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,40 @@
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<MetadataDbContext> _dbContextFactory
)
public class MetadataHandler(IDbContextFactory<MetadataDbContext> _dbContextFactory)
{
// Fetch data by subject (checks both registry and on-chain tables)
public async Task<IResult> GetTokenMetadataAsync(string subject)
public async Task<IResult> 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
{
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,
Expand All @@ -59,7 +57,8 @@ public async Task<IResult> 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.");
Expand All @@ -72,15 +71,15 @@ public async Task<IResult> BatchTokenMetadataAsync(
List<string> distinctSubjects = [.. subjects.Distinct()];

// Build predicate for registry metadata
ExpressionStarter<TokenMetadata> registryPredicate = PredicateBuilder.New<TokenMetadata>(false);
ExpressionStarter<TokenMetadataRegistry> registryPredicate = PredicateBuilder.New<TokenMetadataRegistry>(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));

Expand All @@ -105,9 +104,9 @@ public async Task<IResult> BatchTokenMetadataAsync(
ExpressionStarter<TokenMetadataOnChain> onChainPredicate = PredicateBuilder.New<TokenMetadataOnChain>(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));
Expand All @@ -122,32 +121,36 @@ public async Task<IResult> 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<TokenMetadata> registryTokens = await db.TokenMetadata
// Query both tables sequentially (DbContext is not thread-safe)
List<TokenMetadataRegistry> registryTokens = await db.TokenMetadataRegistry
.AsNoTracking()
.Where(registryPredicate)
.ToListAsync();
.ToListAsync(cancellationToken);

List<TokenMetadataOnChain> onChainTokens = await db.TokenMetadataOnChain
.AsNoTracking()
.Where(onChainPredicate)
.ToListAsync();
.ToListAsync(cancellationToken);

// Convert to dictionaries for O(1) lookups instead of O(n) FirstOrDefault
Dictionary<string, TokenMetadataRegistry> registryBySubject = registryTokens.ToDictionary(t => t.Subject);
Dictionary<string, TokenMetadataOnChain> 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,
Expand All @@ -164,15 +167,18 @@ public async Task<IResult> 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.");
Expand Down
Loading