AZ
Back to constellation
Web3Published

NFTMixer (Go)

A C#/.NET 6 Blazor generative-NFT builder rewritten as a Go 1.26 API + Next.js 16 frontend — parity reached, and the predecessor’s security holes closed.

Go 1.26chiMongoDB (v2 driver)AWS S3 / MinIOSIWE (EIP-4361)secp256k1Next.js 16React FlowPlaywright
Read the write-up

Overview

What it is

NFTMixer (Go) builds generative NFT collections end to end: upload layered PNG art, organise it into sources → assets → colour variants carrying traits and rarity weights, wire a node graph that describes how layers combine and how rare each branch is, generate thousands of weighted-and-unique combinations, hand-curate the survivors, then render final PNGs + OpenSea metadata JSON + SHA-256 sidecars to S3. It is a ground-up rewrite of a C#/.NET 6 Blazor Server app (NFTMixer) into a Go 1.26 (net/http + chi) API plus a Next.js 16 App Router frontend. Slices 0–7 of 8 are built and the design doc records "C# functional parity reached" at the end of slice 6.

The uncomfortable truth about "rewrite it in Go"

The owner wanted long-term ownership of the stack and does not read C#. But Blazor Server is the UI — .razor files are not templates, they are a stateful server-side framework that diffs DOM over a SignalR websocket — and Go has no equivalent. So the design doc says it out loud: "Roughly 70% of this rewrite is frontend work, and none of it is Go." The engine, compositing, database, S3 and auth ports were the genuinely straightforward part; the UI was rebuilt from scratch in TypeScript. Budgeting for that up front is the difference between a plan and a surprise. The migration also carried zero cutover risk by design: existing data was declared expendable and the schema is greenfield, which the design doc names as the single largest source of rewrite risk simply removed.

The layering

The Go module is split so that the algorithmically risky parts are pure and exactly testable, and everything with I/O sits at the edges:

  • internal/domain — pure types + validation, no I/O: project.go, session.go, nonce.go, curation.go, generation.go, output.go, errors.go. Referential integrity between payloads, nodes, variants and rarities is enforced here on write.
  • internal/engine — pure graph analysis: engine.go (path tracing + validation) and generate.go (weighted-and-unique selection). It imports only domain + stdlib, so its algorithms are pinned by fast, exact table tests.
  • internal/rendercomposite.go (layer compositing), filter.go (HSL/contrast/brightness pixel filter), metadata.go (OpenSea JSON).
  • internal/store — Mongo v2 repositories + indexes.go, a declarative index plan applied at startup where every index documents the query it serves.
  • internal/blob — an object-storage port (Store interface) with an aws-sdk-v2 / MinIO implementation. Image bytes live here, never in Mongo.
  • internal/auth — SIWE: siwe.go (EIP-4361 build/parse/verify), crypto.go (secp256k1 recovery + keccak256), service.go (nonce → verify → session).
  • internal/httpapi — chi handlers, middleware, CORS, rate limiting, a shared IResponseData<T> = {code,message,data} envelope.
  • internal/jobs — background render/publish/purge workers that run in-process in the API container for v1.
  • internal/obs — slog logger, /healthz//readyz, request-id, redaction.

The Mongo projects document is the aggregate root: it embeds sources[]→assets[]→variants[], payloads[], graphVersions[]{nodes[],links[]}, rarities[], traits[] and masters[]. Image bytes never enter Mongo — only a blobKey into S3 — so the API is stateless and horizontally scalable, and a ~10 MB write guard rejects an oversized document before it can approach Mongo's 16 MB ceiling. Because the whole aggregate is one document, a single atomic update keeps every payload/node/variant/rarity reference consistent.

Real authentication, as a sequence

The C# app believed whatever wallet address the browser named. The Go version does actual Sign-In-with-Ethereum, and Service.Verify runs the decision in a fixed order that closes each class of forgery:

  1. Parse the message with a strict EIP-4361 parser that re-renders the parsed result and requires byte-for-byte equality with the input — a lenient parser is a differential where the user signs one message and the server believes another.
  2. Verify server expectations — domain, URI, chain id, not-expired.
  3. Recover the signing address from the signature (below).
  4. Consume the nonce (single-use) and assert it was issued to the recovered wallet — a valid signature over someone else's challenge must not sign that someone in.
  5. Cross-check the address printed inside the message equals the recovered one (defence in depth: it is what the user actually saw).
  6. Only then mint the session.

Signature recovery is the entire auth decision, and it deliberately does not pull in go-ethereum — that library is LGPL-3.0 and static-linking it into a proprietary binary carries a relink/source obligation. Recovery needs only secp256k1 + keccak256, both permissively licensed (the same licence reasoning disables next/image's sharp/libvips on the frontend):

v := sig[64] // Ethereum serialises [R||S||V]; dcrd wants [V||R||S]
switch v {
case 0, 1:  v += 27 // some wallets emit a bare recovery id
case 27, 28:
default:    return "", fmt.Errorf("%w: recovery byte %d invalid", ErrBadSignature, v)
}
if isHighS(sig[32:64]) { // reject malleable high-S: one message, two encodings
    return "", fmt.Errorf("%w: S in upper half of curve order (malleable)", ErrBadSignature)
}
compact := make([]byte, signatureLen)
compact[0] = v; copy(compact[1:], sig[:64])
pub, _, err := ecdsa.RecoverCompact(compact, EIP191Hash(message))

The address is then keccak256 over the uncompressed public key (minus its 0x04 prefix), last 20 bytes — and a code comment guards the one subtlety that fails silently: it must be sha3.NewLegacyKeccak256, not sha3.New256, or every login recovers a valid-looking but wrong address with no error to explain why.

Ownership comes from the session, never the URL

No handler on the project surface reads a wallet from a path, body or header to decide who the caller is; it passes the session's wallet to the store. And membership is not re-checked in the handler at all — it is folded into the store's query filter, defined in exactly one place:

func activeFilter(caller string) bson.D {
    return bson.D{
        {Key: "deletedAt", Value: nil},
        {Key: "$or", Value: bson.A{
            bson.D{{Key: "ownerId", Value: caller}},
            bson.D{{Key: "collaboratorIds", Value: caller}},
        }},
    }
}

A non-member's read simply selects no document and returns ErrNotFound — "a handler that forgot to check cannot leak, because there is no unfiltered read to call by mistake." That single filter directly closes the C# DownloadController hole where ownership was read straight out of the URL with no [Authorize].

Sessions that actually expire

The C# session store was a process-local map whose cleanup method was empty. Here every Session carries a hard ExpiresAt (domain Validate refuses a zero one), and Mongo TTL indexes on both sessions and nonces do the reaping — no cron, no sweeper goroutine that can be deployed without its scheduler:

{coll: CollSessions, name: "sessions_ttl", keys: bson.D{{Key: "expiresAt", Value: 1}},
 ttl: true, why: "TTL reaping: the fix for design doc 4.5, sessions that never expired"},
{coll: CollNonces, name: "nonces_ttl", keys: bson.D{{Key: "expiresAt", Value: 1}},
 ttl: true, why: "an unanswered SIWE challenge must not live forever"},

The nonce TTL was deliberately split into its own collection from sessions, and the reaper's expireAfterSeconds is 0 because each field stores an absolute expiry instant — the document dies at the moment the field names. Only sha256(refresh) is ever stored, never the token itself.

One correct generator (weighted and unique)

The C# app shipped two divergent generators — ProcessPaths (weighted, allows duplicates) and ProcessPathsAccurate (unique but uniform-random, with an unbounded while that hangs when the graph cannot yield enough distinct combos). Go collapses them into one weighted selection + uniqueness check with a bounded retry that fails with an actionable exhaustion error instead of spinning:

for produced < want {
    chosen := selectNFT(path, firstIndex, nodeByID, payloads, validVariant, syncPartners, weightOf, rng)
    key := strings.Join(chosen, ",")
    if seen[key] {
        fails++
        if fails >= MaxSelectionRetries { // 200; a synced/single-variant path trips this at once
            avail := Validate(g, payloads).TheoreticalMax
            return GenerateResult{}, &ExhaustionError{Requested: int64(qty), Available: avail}
        }
        continue
    }
    seen[key] = true
    nfts = append(nfts, NFT{Number: number, VariantIDs: chosen})
    number++; produced++; fails = 0
}

Underneath sit three more deliberate corrections, each pinned by a test and each citing the exact C# line it fixes:

  • Absolute rarity is the MIN of a path's step rarities — the rarest step gates the combination — not the product and not the average (a test pins {0.5, 0.8} → 0.5).
  • The DFS cycle guard is continue, not the C# goto: a child already on the path is skipped while its siblings are still explored, where the goto jumped out of the sibling loop and dropped whole parallel branches.
  • Quantity drift is reconciled in a loop until the total equals the request exactly; the C# single pass could not converge when the excess exceeded the paths' combined count.
  • Numbering is gap-free: a number is consumed only by an accepted NFT, where the C# incremented on every attempt including discarded duplicates.

Determinism is a hard requirement: everything the generator ranges is a slice in fixed order, the weight/validity inputs are lookup-only maps, and a single seeded *rand.Rand is consumed in one fixed order — so a stored seed reproduces a run exactly.

Compositing and metadata

Every layer is resized to the output dimensions with a high-quality CatmullRom kernel — a deliberate parity break from the C# app, which resized only the base layer and drew the rest at native size, misaligning any project whose art was not uniformly sized:

resized := image.NewNRGBA(rect)
xdraw.CatmullRom.Scale(resized, rect, filtered, filtered.Bounds(), xdraw.Src, nil)
draw.Draw(canvas, rect, resized, image.Point{}, draw.Over) // first -> last, full alpha

Metadata is OpenSea-standard (name, description, image, external_url, typed attributes) rather than the C# flat {trait: value} dictionary that no marketplace accepts. Crucially the image base URI is a parameter, and a RewriteImageBaseURI pass re-points it — preserving the <number>.png filename and every other byte — so once art is pinned the metadata can be rewritten to ipfs://<cid>/<number>.png without re-compositing. Rendering is byte-different from C# by design (different resampling and PNG encoders), so tests assert on perceptual structure, never image hashes — while the metadata JSON must match exactly.

The predecessor's defects, and where each went

C#/.NET 6 predecessor (real, cited)Go rewrite
Believe-the-browser wallet "auth" — no signature challenge anywhereFull SIWE: nonce → verify → recover signer via secp256k1/keccak256
GET /api/Download/export/db shells to pg_dump, no [Authorize]Endpoint not ported at all (design carve-out)
Zip download builds the path from a URL userId, no membership checkOwnership folded into the store query filter; no unfiltered read exists
Sessions in a process-local map with an empty Cleanse()Hard ExpiresAt + Mongo TTL indexes on sessions and nonces
Committed live Pinata API key/secret in sourceEnv-only, fail-fast at boot; predecessor key treated as burned
Two generators, one with an unbounded while that hangsOne weighted-and-unique generator, bounded retry, actionable exhaustion
Base-layer-only resize misaligns non-uniform artEvery layer resized to output dims with CatmullRom
Flat {trait:value} metadata, not marketplace-compatibleOpenSea schema + parameterised image URI with a rewrite pass

Testing & security posture

Test files sit beside nearly every source file across auth, domain, engine, render, store, httpapi, jobs, blob and obs; store tests run against a throwaway database in the shared replica-set Mongo and tear it down. The repo defines (and the design doc specifies as runnable) gates for gofmt -l, go vet, go test ./... -race, and govulncheck, plus a web tsc + biome check + unit tests + production build and two Playwright legs (a production standalone artifact and a next dev console-hygiene pass) — note the CI workflow file itself is not committed in this repo (only a gitleaks config is), so these are gates as defined, not something I can claim runs on every push. HANDOFF records the web unit suite climbing 436 → 464 → 501 tests with the Playwright suite green.

Hardening: strict CSP + HSTS + X-Content-Type-Options/X-Frame-Options/ Referrer-Policy, self-hosted fonts, low-S malleability rejection, constant-time compares on domain/nonce/admin-wallet checks, and ADMIN_WALLETS bootstrap written with $setOnInsert so no later profile save can self-escalate. Runtime site chrome lives in a DB-backed siteConfig doc editable without redeploy, while security config (JWT_SECRET, SIWE_*, ADMIN_WALLETS, PINATA_JWT) stays env-only and fail-fast.

Honestly-stated limits, straight from the README: graceful shutdown is verified on Linux but not the Windows dev host; the auth rate limiter is per-process, so N replicas mean N× the limit; and refresh-token-reuse revocation is partial — only the same-instant double-use race triggers family revocation, while after-the-fact theft detection needs consumed-token lineage the current schema does not carry.

Related write-ups