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
42 changes: 42 additions & 0 deletions .github/workflows/bootstrap-mainnet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Mainnet UTXO snapshot bootstrap test

# Boots a mainnet node with nipopowBootstrap + utxoBootstrap from an empty data dir and
# waits for a full sync. Validates the fast-bootstrap path end-to-end against the real
# network. Kept separate from release.yml so the multi-hour run cannot block publishing.

on:
release:
types: [published]
workflow_dispatch:

jobs:
bootstrap_mainnet:
name: NiPoPoW + UTXO snapshot bootstrap on mainnet
runs-on: ubuntu-latest
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Java and Scala
uses: olafurpg/setup-scala@v10
with:
java-version: adopt@1.8

- name: Cache sbt
uses: actions/cache@v4
with:
path: |
~/.sbt
~/.ivy2/cache
~/.coursier/cache/v1
~/.cache/coursier/v1
~/AppData/Local/Coursier/Cache/v1
~/Library/Caches/Coursier/Cache/v1
key: ${{ runner.os }}-sbt-cache-v4-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }}

- name: Run mainnet bootstrap test
run: |
mkdir tmp
TMPDIR=$(pwd)/tmp sbt -Denv=test clean ++2.12.20 docker "it2:testOnly *TestUtxoSnapshotBootstrapOnMainNetSpec"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ devnet
.ensime
.ensime_cache/
scorex.yaml
*.iml

# LLM reports on code analysis etc
llm_generated
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.ergoplatform.it2

import com.typesafe.config.{Config, ConfigFactory}
import org.ergoplatform.it.api.NodeApi.NodeInfo
import org.ergoplatform.it.container.{IntegrationSuite, Node}
import org.scalatest.OptionValues
import org.scalatest.flatspec.AnyFlatSpec

import scala.async.Async
import scala.concurrent.Await
import scala.concurrent.duration._

class TestUtxoSnapshotBootstrapOnMainNetSpec
extends AnyFlatSpec
with IntegrationSuite
with OptionValues {

// Unlike TestOnMainNetSpec, no host volume is mounted: node data lives in the container's
// anonymous volume (/home/ergo/.ergo) and is discarded with the container, so every run
// performs a real bootstrap from an empty data dir.

val bootstrapConfig: Config = ConfigFactory.parseString(
s"""
|ergo.node.utxo.utxoBootstrap = true
|ergo.node.nipopow.nipopowBootstrap = true
|# genesisId of mainnet.conf, needed for ErgoSettings validation on the test host,
|# where the network config file is not loaded (see Docker.buildErgoSettings)
|ergo.chain.genesisId = "b0244dfc267baca974a4caee06120321562784303a8a688976ae56170e4d175b"
""".stripMargin
)

val nodeConfig: Config = bootstrapConfig
.withFallback(nodeSeedConfigs.head)
.withFallback(nonGeneratingPeerConfig)
val node: Node = docker.startMainNetNodeYesImSure(nodeConfig).get

it should "Bootstrap from a UTXO set snapshot via NiPoPoW proof on mainnet and fully sync" in {
// Phase 1: headers appear, proving the trusted NiPoPoW proof was applied
val headersResult = Async.async {
Async.await(node.waitFor[NodeInfo](
_.info,
nodeInfo => nodeInfo.bestHeaderHeightOpt.exists(_ > 1000),
1.minute
))
}
val nodeInfoAfterHeaders = Await.result(headersResult, 1.hour)
log.info(s"Headers appeared, best header height: ${nodeInfoAfterHeaders.bestHeaderHeightOpt}")

// Phase 2: wait for a full sync (snapshot applied + full blocks downloaded)
val syncResult = Async.async {
Async.await(node.waitFor[NodeInfo](
_.info,
nodeInfo => nodeInfo.bestBlockHeightOpt.exists(nodeInfo.bestHeaderHeightOpt.contains),
1.minute
))
}
val syncedInfo = Await.result(syncResult, 5.hours)

// guard against a degenerate "synced at genesis" pass
syncedInfo.bestHeaderHeightOpt.value should be > 1000000
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1147,8 +1147,22 @@ class ErgoNodeViewSynchronizer(networkControllerRef: ActorRef,
Seq.empty
}
case _ =>
log.info(s"Processing ${invData.ids.length} non-tx invs (of type $modifierTypeId) from $peer")
invData.ids.filter(mid => deliveryTracker.status(mid, modifierTypeId, Seq(hr)) == ModifiersStatus.Unknown)
// During UTXO set snapshot bootstrap, ignore block-section invs (extension, transactions, ADProofs)
// until the snapshot is applied. Headers and snapshot-related types are still processed.
val utxoBootstrapInProgress =
settings.nodeSettings.utxoSettings.utxoBootstrap &&
!hr.isUtxoSnapshotApplied &&
modifierTypeId != Header.modifierTypeId &&
modifierTypeId != ManifestTypeId.value &&
modifierTypeId != UtxoSnapshotChunkTypeId.value

if (utxoBootstrapInProgress) {
log.debug(s"Ignoring ${invData.ids.length} non-tx invs (of type $modifierTypeId) from $peer: UTXO snapshot bootstrap in progress")
Seq.empty
} else {
log.info(s"Processing ${invData.ids.length} non-tx invs (of type $modifierTypeId) from $peer")
invData.ids.filter(mid => deliveryTracker.status(mid, modifierTypeId, Seq(hr)) == ModifiersStatus.Unknown)
}
}

if (newModifierIds.nonEmpty) {
Expand Down
41 changes: 41 additions & 0 deletions src/main/scala/org/ergoplatform/nodeView/ErgoNodeViewHolder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@ abstract class ErgoNodeViewHolder[State <: ErgoState[State]](settings: ErgoSetti
if (history().isEmpty) {
history().applyPopowProof(proof)
if (!history().isEmpty) {
// When UTXO set snapshot bootstrap is enabled, mark headers chain as synced right after
// a trusted NiPoPoW proof is applied. This allows the node to start requesting UTXO set
// snapshots immediately, instead of waiting for normal header sync to reach the tip.
if (settings.nodeSettings.utxoSettings.utxoBootstrap) {
history().setHeadersChainSynced()
}
updateNodeView(updatedHistory = Some(history()))
}
}
Expand Down Expand Up @@ -583,6 +589,18 @@ abstract class ErgoNodeViewHolder[State <: ErgoState[State]](settings: ErgoSetti
)
}

private def isPreparedUtxoSnapshotState(state: State, history: ErgoHistory): Boolean =
ErgoNodeViewHolder.isPreparedUtxoSnapshotState(
state.isInstanceOf[UtxoState],
settings.nodeSettings.utxoSettings.utxoBootstrap,
history.isUtxoSnapshotApplied,
state.version,
state.rootDigest,
{
val snapshotHeight = history.minimalFullBlockHeight - 1
history.bestHeaderAtHeight(snapshotHeight)
})

private def restoreConsistentState(stateIn: State, history: ErgoHistory): Try[State] = {
(stateIn.version, history.bestFullBlockOpt, stateIn) match {
case (ErgoState.genesisStateVersion, None, _) =>
Expand All @@ -591,8 +609,12 @@ abstract class ErgoNodeViewHolder[State <: ErgoState[State]](settings: ErgoSetti
case (stateId, Some(block), _) if stateId == block.id =>
log.info(s"State and history have the same version ${encoder.encode(stateId)}, no recovery needed.")
Success(stateIn)
case (_, None, _) if isPreparedUtxoSnapshotState(stateIn, history) =>
log.info(s"Prepared UTXO snapshot state ${encoder.encode(stateIn.version)} restored before the first full block")
Success(stateIn)
case (_, None, _) =>
log.info("State and history are inconsistent. History is empty on startup, rollback state to genesis.")
stateIn.closeStorage()
Success(recreatedState())
case (_, Some(bestFullBlock), _: DigestState) =>
log.info(s"State and history are inconsistent. Going to switch state to version ${bestFullBlock.encodedId}")
Expand Down Expand Up @@ -722,6 +744,25 @@ abstract class ErgoNodeViewHolder[State <: ErgoState[State]](settings: ErgoSetti

object ErgoNodeViewHolder {

private[nodeView] def isPreparedUtxoSnapshotState(
stateIsUtxo: Boolean,
utxoBootstrap: => Boolean,
snapshotApplied: => Boolean,
stateVersion: VersionTag,
stateRoot: Array[Byte],
snapshotHeaderOpt: => Option[Header]): Boolean =
stateIsUtxo &&
utxoBootstrap &&
snapshotApplied &&
snapshotHeaderOpt.exists(matchesPreparedUtxoSnapshotHeader(stateVersion, stateRoot, _))

private def matchesPreparedUtxoSnapshotHeader(
stateVersion: VersionTag,
stateRoot: Array[Byte],
header: Header): Boolean =
stateVersion == idToVersion(header.id) &&
java.util.Arrays.equals(stateRoot, header.stateRoot)

object ReceivableMessages {
// Tracking last modifier and header & block heights in time, being periodically checked for possible stuck
case class ChainProgress(lastMod: BlockSection, headersHeight: Int, blockHeight: Int, lastUpdate: Long)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ trait FullBlockPruningProcessor extends MinimalFullBlockHeightFunctions {
*/
def isHeadersChainSynced: Boolean = isHeadersChainSyncedVar

/** Marks headers chain as synchronized. Used by NiPoPoW / UTXO-set-snapshot bootstrap to start
* downloading UTXO set snapshots immediately after a trusted NiPoPoW proof is applied,
* instead of waiting for a "new" header to arrive through normal header sync.
*/
def setHeadersChainSynced(): Unit = {
if (!isHeadersChainSyncedVar) isHeadersChainSyncedVar = true
}

/** Start height to download full blocks from
*/
def minimalFullBlockHeight: Int = readMinimalFullBlockHeight()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ trait ToDownloadProcessor
if (!nodeSettings.verifyTransactions) {
// A regime that do not download and verify transaction
Nil
} else if (nodeSettings.utxoSettings.utxoBootstrap && !isUtxoSnapshotApplied) {
// While bootstrapping from a UTXO set snapshot, do not download full block sections
// until the snapshot has been applied. Block sections downloaded before the snapshot
// would be stored as non-best and never applied to the freshly recreated state.
Nil
} else if (shouldDownloadBlockAtHeight(header.height)) {
// Already synced and header is not too far back. Download required modifiers.
requiredModifiersForHeader(header)
Expand Down
Loading
Loading