AZ
Back to constellation
Web3Published

NFTMixer (.NET)

The original generative-NFT builder — C#/.NET 6, Blazor Server, PostgreSQL/EF Core — a mature product whose security foundations motivated the Go rewrite.

C#.NET 6Blazor ServerEF Core 6 / PostgreSQLSixLabors.ImageSharpDocker
Read the write-up

Overview

What it is

NFTMixer (.NET) is the original generative-NFT builder and the predecessor that nftmixer-go replaces: a C#/.NET 6 Blazor Server application over PostgreSQL via EF Core 6 (lazy-loading proxies), with MudBlazor for UI, Z.Blazor.Diagrams for the node canvas, Nethereum for wallet connect, SixLabors.ImageSharp for compositing, and AWS S3 + local disk for storage. A three-project solution (NftMixer, plus a dead NftGenerator library and a ConsoleTests harness), it reached a real, feature-rich product — sources → assets → variants → traits → rarities → payloads → node graph → generate → curate → render → zip — with ~40 EF migrations spanning 2022–2026. In this workflow it is a read-only reference, kept because the interesting engineering story is the delta between a mature-but-unshippable app and its disciplined rewrite.

Blazor Server is the fork in the road

The whole UI is .razor components rendered server-side and diffed to the browser over a SignalR websocket (AddServerSideBlazor, MapBlazorHub, MapFallbackToPage("/_Host")). That model is convenient — it gave generation progress dialogs "for free" over the socket — but it is precisely the thing with no Go equivalent, which is the honest reason the rewrite was ~70% frontend: the entire interactive surface had to be rebuilt from scratch regardless of how clean the engine port was. Storage compounded it: the app persisted to local disk under {userId}/{mixName}/{n}.png|.json|.png.sha256 on a volume that "must never be lost," which is exactly what blocked safe containerization and drove the Go app's stateless S3-for-everything design. The Dockerfile even runs a three-stage build whose runtime installs postgresql-client because the app shells out to pg_dump.

The live engine, notably, is not in the NftGenerator project at all — that library is dead code. Generation, the graph model and rendering live in Components/ProjectV3.razor (866 lines) and Components/Curation.razor, alongside whole superseded generations of components (Project.razor, Project2.razor, ProjectOLD.razor, an AssetSelectorDialog v1/v2, a ... - Copy.razor) that are referenced but unreachable.

The honest defects that motivated the rewrite

All are real and citable, and each maps to a specific Go-side fix:

  • Authentication that does not authenticate. MainLayout.razor reads GetProviderSelectedAccountAsync() and calls InitUserData(SelectedAccount), which FindAsynces the user or silently creates one — no signature challenge anywhere. The server trusts whatever address the browser names, so anyone can log in as anyone, including an admin:
var enableProvider = await _ethereumHostProvider.EnableProviderAsync();
SelectedAccount = await _ethereumHostProvider.GetProviderSelectedAccountAsync();
if (SelectedAccount != null) {
    await InitUserData(SelectedAccount); // server trusts the address as-is
}
  • An unauthenticated database-dump endpoint. GET /api/Download/export/db shells out to pg_dump and streams back the full data dump, with no [Authorize] on the controller or action:
[HttpGet("export/db")]
public IActionResult ExportDatabase() {
    string connectionString = _configuration.GetConnectionString("DefaultConnection");
    string arguments = $"pg_dump -h {dbHost} -p {dbPort} -U {dbUser} --data-only --column-inserts -Fp {dbName}";
    var commandResult = BashExecutorService.Execute(arguments, backupFilePath, dbPwd);
    if (commandResult.Success) return File(commandResult.file, "application/octet-stream", backupFileName);
}
  • Ownership taken from the URL. GET /api/Download/zipmixer/{userId}/{mixName} builds the output path straight from the URL userId with no membership check, so anyone can download anyone's output.

  • Sessions that never expire. Web3 tokens live in a process-local ConcurrentDictionary whose Cleanse() method is literally empty; the map is an unbounded, never-swept in-memory store:

public static void RegisterToken(string token, Web3User user) {
    _activeTokens.TryAdd(token, (DateTime.UtcNow, user));
    Cleanse();
}
static void Cleanse() {

}
  • A committed live Pinata API key and secret, hardcoded in UploadDataToPinata and committed to git — confirmed present; the values are treated as burned and are never carried forward or reproduced.

  • Unfinished IPFS publishPublishToIpfs returns before uploading, so IPFS never actually worked in the C# app.

The two-generators problem

The clearest illustration of why a rewrite (not a patch) was the right call: the app literally shipped a "Process Paths" button and a "Process Paths (Accurate)" button that produced different collections. The "accurate" path (ProjectV3.razor:607) is unique-but-uniform-random with an unbounded while (generatedCount < stat.NumberToGenerate) that hangs when the graph cannot produce enough distinct combinations, and it does nftNumber++ on every attempt, leaving gaps in the numbering. The Go rewrite folds both into a single weighted-and-unique generator with a bounded retry and gap-free numbering.

Why keep it in the constellation

It was not a toy — ~40 migrations, V3 generation, masters, rarity tiers, real rendered output. That maturity is the point: the story worth telling is not "an app was rewritten" but "a working, feature-complete product had foundations — statefulness, spoofable auth, an open dump endpoint, a committed key — that could not be kept," and what a disciplined rewrite does with that inheritance.

Related write-ups

NFTMixer (.NET) · Abdullah Zakariyya