Repository: celestiaorg/celestia-app

Issue:
Add a test for state sync
## Context

https://github.com/celestiaorg/celestia-app/issues/3804

## Problem

We discovered a bug with state sync on a testnet. Ideally this should've been caught via a unit or integration test.

## Proposal

Add a test that tries to state sync in a manner that will catch regressions like https://github.com/celestiaorg/celestia-app/issues/3804


Hints from issue comments:

Solution patch:
diff --git a/app/app.go b/app/app.go
index 134da07e..884d40c1 100644
--- a/app/app.go
+++ b/app/app.go
@@ -570,6 +570,7 @@ func (app *App) InitChain(req abci.RequestInitChain) (res abci.ResponseInitChain
 // mountKeysAndInit mounts the keys for the provided app version and then
 // invokes baseapp.Init().
 func (app *App) mountKeysAndInit(appVersion uint64) {
+	app.BaseApp.Logger().Debug(fmt.Sprintf("mounting KV stores for app version %v", appVersion))
 	app.MountKVStores(app.versionedKeys(appVersion))
 
 	// Invoke load latest version for it's side-effect of invoking baseapp.Init()
@@ -784,3 +785,29 @@ func (app *App) InitializeAppVersion(ctx sdk.Context) {
 		app.SetAppVersion(ctx, appVersion)
 	}
 }
+
+// OfferSnapshot is a wrapper around the baseapp's OfferSnapshot method. It is
+// needed to mount stores for the appropriate app version.
+func (app *App) OfferSnapshot(req abci.RequestOfferSnapshot) abci.ResponseOfferSnapshot {
+	if app.IsSealed() {
+		// If the app is sealed, keys have already been mounted so this can
+		// delegate to the baseapp's OfferSnapshot.
+		return app.BaseApp.OfferSnapshot(req)
+	}
+
+	if app.upgradeHeightV2 == 0 {
+		app.Logger().Debug("v2 upgrade height not set, assuming app version 2")
+		app.mountKeysAndInit(v2)
+		return app.BaseApp.OfferSnapshot(req)
+	}
+
+	if req.Snapshot.Height >= uint64(app.upgradeHeightV2) {
+		app.Logger().Debug("snapshot height is greater than or equal to upgrade height, assuming app version 2")
+		app.mountKeysAndInit(v2)
+		return app.BaseApp.OfferSnapshot(req)
+	}
+
+	app.Logger().Debug("snapshot height is less than upgrade height, assuming app version 1")
+	app.mountKeysAndInit(v1)
+	return app.BaseApp.OfferSnapshot(req)
+}
diff --git a/scripts/arabica.sh b/scripts/arabica.sh
new file mode 100755
index 00000000..831c7852
--- /dev/null
+++ b/scripts/arabica.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+
+# This script starts a consensus node on Arabica and state syncs to the tip of
+# the chain.
+
+# Stop script execution if an error is encountered
+set -o errexit
+# Stop script execution if an undefined variable is used
+set -o nounset
+
+CHAIN_ID="arabica-11"
+NODE_NAME="node-name"
+SEEDS="827583022cc6ce65cf762115642258f937c954cd@validator-1.celestia-arabica-11.com:26656,74e42b39f512f844492ff09e30af23d54579b7bc@validator-2.celestia-arabica-11.com:26656,00d577159b2eb1f524ef9c37cb389c020a2c38d2@validator-3.celestia-arabica-11.com:26656,b2871b6dc2e18916d07264af0e87c456c2bba04f@validator-4.celestia-arabica-11.com:26656"
+RPC="https://rpc.celestia-arabica-11.com:443"
+
+CELESTIA_APP_HOME="${HOME}/.celestia-app"
+CELESTIA_APP_VERSION=$(celestia-appd version 2>&1)
+
+echo "celestia-app home: ${CELESTIA_APP_HOME}"
+echo "celestia-app version: ${CELESTIA_APP_VERSION}"
+echo ""
+
+# Ask the user for confirmation before deleting the existing celestia-app home
+# directory.
+read -p "Are you sure you want to delete: $CELESTIA_APP_HOME? [y/n] " response
+
+# Check the user's response
+if [ "$response" != "y" ]; then
+    # Exit if the user did not respond with "y"
+    echo "You must delete $CELESTIA_APP_HOME to continue."
+    exit 1
+fi
+
+echo "Deleting $CELESTIA_APP_HOME..."
+rm -r "$CELESTIA_APP_HOME"
+
+echo "Initializing config files..."
+celestia-appd init ${NODE_NAME} --chain-id ${CHAIN_ID} > /dev/null 2>&1 # Hide output to reduce terminal noise
+
+echo "Settings seeds in config.toml..."
+sed -i.bak -e "s/^seeds *=.*/seeds = \"$SEEDS\"/" $CELESTIA_APP_HOME/config/config.toml
+
+# LATEST_HEIGHT=$(curl -s $RPC/block | jq -r .result.block.header.height);
+BLOCK_HEIGHT=1751700
+TRUST_HASH=$(curl -s "$RPC/block?height=$BLOCK_HEIGHT" | jq -r .result.block_id.hash)
+
+echo "Block height: $BLOCK_HEIGHT"
+echo "Trust hash: $TRUST_HASH"
+echo "Enabling state sync in config.toml..."
+sed -i.bak -E "s|^(enable[[:space:]]+=[[:space:]]+).*$|\1true| ; \
+s|^(rpc_servers[[:space:]]+=[[:space:]]+).*$|\1\"$RPC,$RPC\"| ; \
+s|^(trust_height[[:space:]]+=[[:space:]]+).*$|\1$BLOCK_HEIGHT| ; \
+s|^(trust_hash[[:space:]]+=[[:space:]]+).*$|\1\"$TRUST_HASH\"|" $HOME/.celestia-app/config/config.toml
+
+echo "Downloading genesis file..."
+celestia-appd download-genesis ${CHAIN_ID}
+
+echo "Starting celestia-appd..."
+celestia-appd start --v2-upgrade-height 1751707
diff --git a/scripts/mocha.sh b/scripts/mocha.sh
new file mode 100755
index 00000000..d64921b2
--- /dev/null
+++ b/scripts/mocha.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+
+# This script starts a consensus node on Mocha and state syncs to the tip of the
+# chain.
+
+# Stop script execution if an error is encountered
+set -o errexit
+# Stop script execution if an undefined variable is used
+set -o nounset
+
+CHAIN_ID="mocha-4"
+NODE_NAME="node-name"
+SEEDS="ee9f90974f85c59d3861fc7f7edb10894f6ac3c8@seed-mocha.pops.one:26656,258f523c96efde50d5fe0a9faeea8a3e83be22ca@seed.mocha-4.celestia.aviaone.com:20279,5d0bf034d6e6a8b5ee31a2f42f753f1107b3a00e@celestia-testnet-seed.itrocket.net:11656,7da0fb48d6ef0823bc9770c0c8068dd7c89ed4ee@celest-test-seed.theamsolutions.info:443"
+RPC="https://celestia-testnet-rpc.itrocket.net:443"
+
+CELESTIA_APP_HOME="${HOME}/.celestia-app"
+CELESTIA_APP_VERSION=$(celestia-appd version 2>&1)
+
+echo "celestia-app home: ${CELESTIA_APP_HOME}"
+echo "celestia-app version: ${CELESTIA_APP_VERSION}"
+echo ""
+
+# Ask the user for confirmation before deleting the existing celestia-app home
+# directory.
+read -p "Are you sure you want to delete: $CELESTIA_APP_HOME? [y/n] " response
+
+# Check the user's response
+if [ "$response" != "y" ]; then
+    # Exit if the user did not respond with "y"
+    echo "You must delete $CELESTIA_APP_HOME to continue."
+    exit 1
+fi
+
+echo "Deleting $CELESTIA_APP_HOME..."
+rm -r "$CELESTIA_APP_HOME"
+
+echo "Initializing config files..."
+celestia-appd init ${NODE_NAME} --chain-id ${CHAIN_ID} > /dev/null 2>&1 # Hide output to reduce terminal noise
+
+echo "Settings seeds in config.toml..."
+sed -i.bak -e "s/^seeds *=.*/seeds = \"$SEEDS\"/" $CELESTIA_APP_HOME/config/config.toml
+
+LATEST_HEIGHT=$(curl -s $RPC/block | jq -r .result.block.header.height);
+BLOCK_HEIGHT=$((LATEST_HEIGHT - 2000)); \
+TRUST_HASH=$(curl -s "$RPC/block?height=$BLOCK_HEIGHT" | jq -r .result.block_id.hash)
+
+echo "Block height: $BLOCK_HEIGHT"
+echo "Trust hash: $TRUST_HASH"
+echo "Enabling state sync in config.toml..."
+sed -i.bak -E "s|^(enable[[:space:]]+=[[:space:]]+).*$|\1true| ; \
+s|^(rpc_servers[[:space:]]+=[[:space:]]+).*$|\1\"$RPC,$RPC\"| ; \
+s|^(trust_height[[:space:]]+=[[:space:]]+).*$|\1$BLOCK_HEIGHT| ; \
+s|^(trust_hash[[:space:]]+=[[:space:]]+).*$|\1\"$TRUST_HASH\"|" $HOME/.celestia-app/config/config.toml
+
+echo "Downloading genesis file..."
+celestia-appd download-genesis ${CHAIN_ID} > /dev/null 2>&1 # Hide output to reduce terminal noise
+
+echo "Starting celestia-appd..."
+celestia-appd start --v2-upgrade-height 2585031



Test patch:
diff --git a/app/app_test.go b/app/app_test.go
index a991981d..c9fb31c1 100644
--- a/app/app_test.go
+++ b/app/app_test.go
@@ -6,7 +6,12 @@ import (
 	"github.com/celestiaorg/celestia-app/v2/app"
 	"github.com/celestiaorg/celestia-app/v2/app/encoding"
 	"github.com/celestiaorg/celestia-app/v2/x/minfee"
+	"github.com/cosmos/cosmos-sdk/baseapp"
+	"github.com/cosmos/cosmos-sdk/snapshots"
+	snapshottypes "github.com/cosmos/cosmos-sdk/snapshots/types"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	abci "github.com/tendermint/tendermint/abci/types"
 	"github.com/tendermint/tendermint/libs/log"
 	tmdb "github.com/tendermint/tm-db"
 )
@@ -47,6 +52,45 @@ func TestNew(t *testing.T) {
 	})
 }
 
+func TestOfferSnapshot(t *testing.T) {
+	logger := log.NewNopLogger()
+	db := tmdb.NewMemDB()
+	traceStore := &NoopWriter{}
+	invCheckPeriod := uint(1)
+	encodingConfig := encoding.MakeConfig(app.ModuleEncodingRegisters...)
+	upgradeHeight := int64(0)
+	appOptions := NoopAppOptions{}
+	snapshotOption := getSnapshotOption(t)
+	app := app.New(logger, db, traceStore, invCheckPeriod, encodingConfig, upgradeHeight, appOptions, snapshotOption)
+
+	t.Run("should return ACCEPT", func(t *testing.T) {
+		request := abci.RequestOfferSnapshot{
+			Snapshot: &abci.Snapshot{
+				Height:   0x1b07ec,
+				Format:   0x2,
+				Chunks:   0x1,
+				Hash:     []uint8{0xaf, 0xa5, 0xe, 0x16, 0x45, 0x4, 0x2e, 0x45, 0xd3, 0x49, 0xdf, 0x83, 0x2a, 0x57, 0x9d, 0x64, 0xc8, 0xad, 0xa5, 0xb, 0x65, 0x1b, 0x46, 0xd6, 0xc3, 0x85, 0x6, 0x51, 0xd7, 0x45, 0x8e, 0xb8},
+				Metadata: []uint8{0xa, 0x20, 0xaf, 0xa5, 0xe, 0x16, 0x45, 0x4, 0x2e, 0x45, 0xd3, 0x49, 0xdf, 0x83, 0x2a, 0x57, 0x9d, 0x64, 0xc8, 0xad, 0xa5, 0xb, 0x65, 0x1b, 0x46, 0xd6, 0xc3, 0x85, 0x6, 0x51, 0xd7, 0x45, 0x8e, 0xb8},
+			},
+			AppHash: []byte("apphash"),
+		}
+		want := abci.ResponseOfferSnapshot{Result: abci.ResponseOfferSnapshot_ACCEPT}
+		got := app.OfferSnapshot(request)
+		assert.Equal(t, want, got)
+	})
+}
+
+func getSnapshotOption(t *testing.T) func(*baseapp.BaseApp) {
+	snapshotDir := t.TempDir()
+	snapshotDB, err := tmdb.NewDB("metadata", tmdb.GoLevelDBBackend, t.TempDir())
+	require.NoError(t, err)
+	snapshotStore, err := snapshots.NewStore(snapshotDB, snapshotDir)
+	require.NoError(t, err)
+	interval := uint64(10)
+	keepRecent := uint32(10)
+	return baseapp.SetSnapshot(snapshotStore, snapshottypes.NewSnapshotOptions(interval, keepRecent))
+}
+
 // NoopWriter is a no-op implementation of a writer.
 type NoopWriter struct{}