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) andgenerate.go(weighted-and-unique selection). It imports only domain + stdlib, so its algorithms are pinned by fast, exact table tests.internal/render—composite.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 (Storeinterface) 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 sharedIResponseData<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:
- 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.
- Verify server expectations — domain, URI, chain id, not-expired.
- Recover the signing address from the signature (below).
- 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.
- Cross-check the address printed inside the message equals the recovered one (defence in depth: it is what the user actually saw).
- 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 thegotojumped 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 anywhere | Full 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 check | Ownership 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 source | Env-only, fail-fast at boot; predecessor key treated as burned |
Two generators, one with an unbounded while that hangs | One weighted-and-unique generator, bounded retry, actionable exhaustion |
| Base-layer-only resize misaligns non-uniform art | Every layer resized to output dims with CatmullRom |
Flat {trait:value} metadata, not marketplace-compatible | OpenSea 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.