feat(genesis-writer): add offline chain history population tool#210
Conversation
Replaces genesis-replay with a fully offline tool that reads from a source DP database and writes real CometBFT blocks directly to Core chain PostgreSQL + blockstore.db + state.db. Produces a distributable snapshot that third-party indexers can process from block 1. Includes ManageEntityLegacyMigration proto type to distinguish genesis migration transactions from live ones, managed postgres lifecycle, auto-generated validator keys and genesis.json, and resume support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new cmd/genesis-writer tool to bootstrap a Core chain from an existing Discovery Provider Postgres DB by emitting synthetic CometBFT blocks directly into Core’s DB tables and writing CometBFT state/blockstore data so a node can start at the migrated height.
Changes:
- Introduces
ManageEntityLegacyMigrationas a newSignedTransactionvariant and wires it into Core’s ABCI finalize path. - Adds
cmd/genesis-writer(writer, CometBFT state priming, managed local Postgres option) plus an end-to-end Docker Compose integration test + seed data. - Updates dependencies to support the new CLI tool and Postgres/migrations usage.
Reviewed changes
Copilot reviewed 26 out of 28 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| proto/core/v1/types.proto | Adds ManageEntityLegacyMigration message + SignedTransaction oneof case. |
| pkg/core/server/manage_entity.go | Adds finalize handler for migration manage-entity txs. |
| pkg/core/server/abci.go | Improves snapshot offer handling and routes migration txs through finalizeTransaction. |
| go.mod | Adds direct deps for genesis-writer (cli, pq) and related indirects. |
| go.sum | Updates module checksums after dependency changes. |
| cmd/genesis-writer/main.go | CLI entrypoint, flag parsing, key resolution, managed-postgres startup. |
| cmd/genesis-writer/writer.go | Core migration writer: reads DP entities, builds/signs txs, builds blocks, writes to Core DB + blockstore. |
| cmd/genesis-writer/batch.go | Generic batched/concurrent entity processing helpers. |
| cmd/genesis-writer/cmt_state.go | Loads genesis + validator key, opens blockstore, bootstraps CometBFT state.db, writes updated genesis.json. |
| cmd/genesis-writer/postgres.go | Local managed Postgres cluster lifecycle for offline runs. |
| cmd/genesis-writer/entities_user.go | User + wallet-related entity extraction/serialization. |
| cmd/genesis-writer/entities_track.go | Track + track-download extraction/serialization. |
| cmd/genesis-writer/entities_playlist.go | Playlist extraction/serialization. |
| cmd/genesis-writer/entities_social.go | Social actions extraction/serialization (follows/saves/reposts/etc). |
| cmd/genesis-writer/entities_play.go | Play event extraction into TrackPlays txs. |
| cmd/genesis-writer/entities_developer_app.go | Developer app + grant extraction/serialization. |
| cmd/genesis-writer/entities_dashboard_wallet.go | Dashboard wallet user extraction/serialization. |
| cmd/genesis-writer/entities_comment.go | Comment + comment-reaction extraction/serialization. |
| cmd/genesis-writer/entities_email.go | Encrypted email + email access extraction/serialization. |
| cmd/genesis-writer/entities_tip.go | Tip reaction extraction/serialization. |
| cmd/genesis-writer/integration_test.go | Docker-based integration test validating round-trip + consensus advancement + state sync. |
| cmd/genesis-writer/docker-compose.yml | Integration-test stack (source DP DB, core DBs, ganache, ingress, nodes). |
| cmd/genesis-writer/README.md | Tool documentation, usage, and integration test instructions. |
| cmd/genesis-writer/Makefile | Convenience target to run integration test flow. |
| cmd/genesis-writer/testdata/source_init.sh | Initializes/creates the seeded source DB in Docker. |
| cmd/genesis-writer/testdata/seed.sql | Comprehensive DP seed dataset for integration tests. |
| cmd/genesis-writer/testdata/dp_seed.sql | Minimal DP seed to satisfy DP indexer assumptions. |
Comments suppressed due to low confidence (1)
pkg/core/server/abci.go:597
- In the "new snapshot offered" branch, acceptedSnapshotHeight/Hash are cleared but execution continues into the hash-mismatch check. Since acceptedSnapshotHash is now nil, this will always reject the newly offered snapshot (even though we intended to accept it). Restructure this logic so that when height differs you either (a) immediately treat it as a fresh offer (skip the hash check) or (b) update acceptedSnapshotHeight/Hash to the new snapshot before validating further state.
// If we've already accepted a snapshot, check if CometBFT is re-offering the
// same one (resume) or a different one (previous snapshot failed verification).
if s.acceptedSnapshotHeight != 0 {
if req.Snapshot.Height != s.acceptedSnapshotHeight {
// CometBFT is offering a different snapshot, which means the previously
// accepted one failed (e.g. consensus params verification error). Clear
// the old state so we can accept the new snapshot.
s.logger.Info("clearing previous snapshot state: CometBFT offered a new snapshot",
zap.Uint64("previous_height", s.acceptedSnapshotHeight),
zap.Uint64("new_height", req.Snapshot.Height))
s.acceptedSnapshotHeight = 0
s.acceptedSnapshotHash = nil
}
// Check hash matches too
if !bytes.Equal(req.Snapshot.Hash, s.acceptedSnapshotHash) {
s.logger.Info("rejecting snapshot: hash mismatch",
zap.Uint64("height", req.Snapshot.Height),
zap.String("offered_hash", hex.EncodeToString(req.Snapshot.Hash)),
zap.String("accepted_hash", hex.EncodeToString(s.acceptedSnapshotHash)))
return &abcitypes.OfferSnapshotResponse{
Result: abcitypes.OFFER_SNAPSHOT_RESULT_REJECT,
}, nil
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Fix blockWriteErr data race with atomic.Pointer, restore block linkage on resume, wire BatchSize config, add defer stopBlockWriter for leak safety, use streaming sha256 for appHash, fix README SaveBlock wording. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Remove redundant sql.Open/Close before RunMigrations - Handle json.Marshal errors in social and comment entity writers - Merge into existing app_state instead of replacing it in writeGenesisFile - Improve README SaveBlock documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Load prevAppHash from core_app_state instead of block header (off-by-one fix) - Make blockstore required for resume (error if CMTHome not set) - Error on missing block/commit in blockstore during resume - Document that Signer field is an identity hint, not signature authority - Use uppercase hex for tx_hash to match CometBFT's HexBytes.String() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
pkg/core/server/abci.go:598
- In the "accepted snapshot already set" branch, when a different snapshot height is offered you reset acceptedSnapshotHeight/Hash to allow accepting a new snapshot, but the function then continues into the hash-mismatch check and will reject because acceptedSnapshotHash is now nil. Restructure this logic so that when the offered height differs you clear state and then fall through to the "first snapshot" validation/accept path (skipping the hash check for the previous snapshot).
if s.acceptedSnapshotHeight != 0 {
if req.Snapshot.Height != s.acceptedSnapshotHeight {
// CometBFT is offering a different snapshot, which means the previously
// accepted one failed (e.g. consensus params verification error). Clear
// the old state so we can accept the new snapshot.
s.logger.Info("clearing previous snapshot state: CometBFT offered a new snapshot",
zap.Uint64("previous_height", s.acceptedSnapshotHeight),
zap.Uint64("new_height", req.Snapshot.Height))
s.acceptedSnapshotHeight = 0
s.acceptedSnapshotHash = nil
}
// Check hash matches too
if !bytes.Equal(req.Snapshot.Hash, s.acceptedSnapshotHash) {
s.logger.Info("rejecting snapshot: hash mismatch",
zap.Uint64("height", req.Snapshot.Height),
zap.String("offered_hash", hex.EncodeToString(req.Snapshot.Hash)),
zap.String("accepted_hash", hex.EncodeToString(s.acceptedSnapshotHash)))
return &abcitypes.OfferSnapshotResponse{
Result: abcitypes.OFFER_SNAPSHOT_RESULT_REJECT,
}, nil
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Write to blockstore after postgres commit to keep them in sync on failure - Return ctx.Err() on interruption instead of breaking to success path - Update README: indexers must recover signer from signature, not trust the signer field (which carries the entity wallet address) - Document step-based resume granularity limitation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rr, user lookup - Sort imports in integration_test.go per gofmt - Fall back to metadata_multihash for TrackCID when track_segments empty - Add ORDER BY to wallet→user preload for deterministic tip attribution - Check rows.Err() after iterating resume progress query - Use os/user.Current() as fallback when USER env var is unset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
pkg/core/server/abci.go:598
- If a different snapshot height is offered, the code clears acceptedSnapshotHeight/Hash but then immediately compares the offered hash against the now-nil acceptedSnapshotHash and will always reject. After clearing state, it should fall through to the “first snapshot” accept path (or reinitialize acceptedSnapshotHash) rather than performing the hash mismatch check on a cleared value.
// If we've already accepted a snapshot, check if CometBFT is re-offering the
// same one (resume) or a different one (previous snapshot failed verification).
if s.acceptedSnapshotHeight != 0 {
if req.Snapshot.Height != s.acceptedSnapshotHeight {
// CometBFT is offering a different snapshot, which means the previously
// accepted one failed (e.g. consensus params verification error). Clear
// the old state so we can accept the new snapshot.
s.logger.Info("clearing previous snapshot state: CometBFT offered a new snapshot",
zap.Uint64("previous_height", s.acceptedSnapshotHeight),
zap.Uint64("new_height", req.Snapshot.Height))
s.acceptedSnapshotHeight = 0
s.acceptedSnapshotHash = nil
}
// Check hash matches too
if !bytes.Equal(req.Snapshot.Hash, s.acceptedSnapshotHash) {
s.logger.Info("rejecting snapshot: hash mismatch",
zap.Uint64("height", req.Snapshot.Height),
zap.String("offered_hash", hex.EncodeToString(req.Snapshot.Hash)),
zap.String("accepted_hash", hex.EncodeToString(s.acceptedSnapshotHash)))
return &abcitypes.OfferSnapshotResponse{
Result: abcitypes.OFFER_SNAPSHOT_RESULT_REJECT,
}, nil
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The Python DP indexer sets track_cid from the metadata JSON field "track_cid", not from track_segments or metadata_multihash. metadata_multihash is the CID of the metadata blob itself (unrelated to the audio CID), so using it as a fallback was incorrect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Managed postgres uses trust auth for bulk-load performance. Ensure it only listens on localhost to prevent exposing an unauthenticated instance on the network. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 28 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
pkg/core/server/abci.go:598
- In the height-mismatch case you clear
acceptedSnapshotHeight/acceptedSnapshotHash, but then still run the hash-mismatch check against a nilacceptedSnapshotHash, which will reject the newly offered snapshot. After clearing, this branch should fall through to the “First snapshot, validate and accept it” path (e.g., return ACCEPT after re-validating, or restructure with anelse/gotoso the hash check only runs when the snapshot height matches).
if s.acceptedSnapshotHeight != 0 {
if req.Snapshot.Height != s.acceptedSnapshotHeight {
// CometBFT is offering a different snapshot, which means the previously
// accepted one failed (e.g. consensus params verification error). Clear
// the old state so we can accept the new snapshot.
s.logger.Info("clearing previous snapshot state: CometBFT offered a new snapshot",
zap.Uint64("previous_height", s.acceptedSnapshotHeight),
zap.Uint64("new_height", req.Snapshot.Height))
s.acceptedSnapshotHeight = 0
s.acceptedSnapshotHash = nil
}
// Check hash matches too
if !bytes.Equal(req.Snapshot.Hash, s.acceptedSnapshotHash) {
s.logger.Info("rejecting snapshot: hash mismatch",
zap.Uint64("height", req.Snapshot.Height),
zap.String("offered_hash", hex.EncodeToString(req.Snapshot.Hash)),
zap.String("accepted_hash", hex.EncodeToString(s.acceptedSnapshotHash)))
return &abcitypes.OfferSnapshotResponse{
Result: abcitypes.OFFER_SNAPSHOT_RESULT_REJECT,
}, nil
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
# Conflicts: # pkg/api/core/v1/types.pb.go # proto/core/v1/types.proto
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ck ID on resume Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Adds
cmd/genesis-writer, a one-time migration tool that populates a new Core chain with the full historical Audius state by writing synthetic CometBFT blocks directly to PostgreSQL and blockstore.db — no running network or consensus needed.The Audius protocol is migrating from a Python-based Discovery Provider architecture to a Go-based consensus chain (CometBFT/Core). ~6 years of on-chain state (users, tracks, playlists, social graph, plays, comments, developer apps, etc.) currently lives in the DP's PostgreSQL database. Without this tool, either the chain starts empty and all historical data is lost, or every historical transaction must be replayed through live consensus — infeasible for millions of transactions.
The genesis writer solves this by reading every current, non-deleted entity from a source DP database, wrapping each in a
ManageEntityLegacyMigrationproto signed with the genesis migration keypair, packing them into structurally valid CometBFT blocks (with proper headers, commits, chain linkage, and signatures), and writing them tocore_blocks/core_transactions/core_app_state. After writing, it primes CometBFT'sstate.dbandblockstore.dbso a single bootstrap node can start from the written height and immediately propose the next live block. Other nodes can then state-sync from it.Architecture
Data flow:
Key components:
main.go— CLI withwritesubcommand, flag handling, managed postgres auto-startwriter.go— Core engine: async block writer pipeline, block construction, signing, COPY-based bulk inserts, step-based resumecmt_state.go— CometBFT state.db/blockstore.db initialization, genesis.json patchingbatch.go— Generic concurrent batch processor (processBatched[T]) withNumCPUworker poolpostgres.go— Optional self-managed local postgres instance for destinationentities_*.go— One file per domain (users, tracks, playlists, social, plays, comments, developer apps, emails, tips, dashboard wallets)Entity processing phases (dependency order):
Design Decisions & Tradeoffs
New proto type (
ManageEntityLegacyMigration) vs reusingManageEntityLegacyChose a separate proto message (structurally identical fields) so indexers get an unambiguous signal that a transaction came from genesis migration. This allows different validation rules: skip wallet-ownership checks, verify against the migration authority key instead of the Signer field. A flag on
ManageEntityLegacywould be fragile and potentially spoofable in live transactions.Direct DB writes vs consensus replay
Writing directly to postgres and blockstore is orders of magnitude faster than feeding transactions through actual consensus. The blocks are syntactically valid CometBFT blocks with proper chain linkage (prevBlockID, commits, app hashes), so a node can boot from this state. The tradeoff is that these blocks were never executed through ABCI — the app hashes are synthetic (
SHA256(concat of tx bytes)) rather than ABCI-derived. This is acceptable because no node will ever re-execute genesis-range blocks.Signer override semantics
All transactions are signed with a single migration key, then the
Signerfield is overwritten with the entity's real wallet address. The signature will NOT recover to the Signer value. Indexers must verify authority by recovering from the signature and checking against the genesis migration authority —Signeris an identity hint only. This is documented in the code and README.No ABCI side-effects for migration transactions
finalizeManageEntityMigrationinabci.gois a pass-through — it doesn't populatesound_recordingsormanagement_keyslikefinalizeManageEntitydoes for live Track creates. The ETL must handle migration transactions end-to-end. This is correct because genesis data doesn't carry live CIDs.Block time is synthetic
Block time starts at
genesisTimeand increments by 1 second per block, unrelated to when entities were actually created. Entity timestamps are preserved in transaction metadata for the ETL to use.Concurrent entity processing
processBatchedfans out toNumCPUgoroutines for signing/marshaling, serialized byblockMufor block assembly. Transaction ordering within an entity type is non-deterministic, which is acceptable for genesis data.Play data coverage gap
The DP
playstable is pruned to ~400 days.aggregate_playshas lifetime totals butaggregate_monthly_playsovercounts by ~2x due to country-dimension duplication. The current PR writes only raw plays that exist. Historical play count reconciliation is a follow-up PR.Rollout Strategy
PR Sequence
This PR is the first of three:
ManageEntityLegacyMigrationproto + ABCI pass-through handlerPlayCount/Reconcileentity type) to cover the ~400-day play data gap usingaggregate_playsas ground truthManageEntityMigrationtransaction type, so the indexer can consume genesis blocks and populate domain tablesThis ordering is safe: the genesis writer writes blocks to the chain DB, and nothing reads
ManageEntityMigrationtransactions until the ETL PR lands. The writer can be run against production data independently to validate output before the ETL handler is ready.Network Migration
The genesis writer is one piece of a larger network cutover from the existing Core chain to a new chain bootstrapped with full historical state. The migration follows the same dual-write pattern used in previous network transitions (POA → Nethermind, Solana → Core), adapted for this chain-to-chain migration. No write-freeze or downtime is required.
Phase 1: Snapshot + Bootstrap
genesis_migration_addressandgenesis_migration_end_height.pkg/core/config/genesis/prod.json) and build a new release binary.Phase 2: Dual-Write + Catch-Up
Phase 3: Infrastructure Cutover
rpc.audius.engineeringandgrpc.audius.engineeringnodes to the new chain ID and genesis.json. These nodes state-sync from the bootstrap node to join the new chain.Phase 4: Validator Migration
Key Invariants
genesis_migration_end_heightin genesis.json ensures the migration authority cannot mint new entities after the genesis range--resumepicks up from the last completed entity-type stepTest Plan
TestGenesisWriter) covering write → index → verify → consensus → state-sync lifecycle🤖 Generated with Claude Code