Repository: celestiaorg/celestia-app

Issue:
Clarify specs for v1 shares
## Context

maciej@eiger.co raised a question on [Slack](https://celestia-team.slack.com/archives/C0639CBCAF7/p1742486557102909)

> Question regarding share v1. Should we only return signer if we are outside of reserved namespaces, or should we ignore the namespace and always try to interpret bytes after seq_len as signer, even if those are txs shares?

## Proposal

Clarify in the specs: 
- The reserved namespaces currently use share version 0
- ~~If share version 1 is used, a signer should be read immediately after sequence len~~ already stated [here](https://github.com/rootulp/celestia-app/blob/153cc6fa33d6053c6a1b47aa901c3cb39bc4e679/specs/src/shares.md?plain=1#L55)

Discussion:

Solution patch:
diff --git a/app/app.go b/app/app.go
index 9087d531..34463010 100644
--- a/app/app.go
+++ b/app/app.go
@@ -33,6 +33,7 @@ import (
 	"github.com/celestiaorg/celestia-app/v3/x/tokenfilter"
 	"github.com/cosmos/cosmos-sdk/baseapp"
 	"github.com/cosmos/cosmos-sdk/client"
+	"github.com/cosmos/cosmos-sdk/client/flags"
 	nodeservice "github.com/cosmos/cosmos-sdk/client/grpc/node"
 	"github.com/cosmos/cosmos-sdk/client/grpc/tmservice"
 	"github.com/cosmos/cosmos-sdk/codec"
@@ -154,7 +155,7 @@ type App struct {
 	DistrKeeper         distrkeeper.Keeper
 	GovKeeper           govkeeper.Keeper
 	CrisisKeeper        crisiskeeper.Keeper
-	UpgradeKeeper       upgradekeeper.Keeper // This is included purely for the IBC Keeper. It is not used for upgrading
+	UpgradeKeeper       upgradekeeper.Keeper
 	SignalKeeper        signal.Keeper
 	ParamsKeeper        paramskeeper.Keeper
 	IBCKeeper           *ibckeeper.Keeper // IBCKeeper must be a pointer in the app, so we can SetRouter on it correctly
@@ -269,10 +270,8 @@ func New(
 	)
 
 	app.FeeGrantKeeper = feegrantkeeper.NewKeeper(appCodec, keys[feegrant.StoreKey], app.AccountKeeper)
-	// The upgrade keeper is initialised solely for the ibc keeper which depends on it to know what the next validator hash is for after the
-	// upgrade. This keeper is not used for the actual upgrades but merely for compatibility reasons. Ideally IBC has their own upgrade module
-	// for performing IBC based upgrades. Note, as we use rolling upgrades, IBC technically never needs this functionality.
-	app.UpgradeKeeper = upgradekeeper.NewKeeper(nil, keys[upgradetypes.StoreKey], appCodec, "", app.BaseApp, authtypes.NewModuleAddress(govtypes.ModuleName).String())
+	homePath := cast.ToString(appOpts.Get(flags.FlagHome))
+	app.UpgradeKeeper = upgradekeeper.NewKeeper(nil, keys[upgradetypes.StoreKey], appCodec, homePath, app.BaseApp, authtypes.NewModuleAddress(govtypes.ModuleName).String())
 
 	app.BlobstreamKeeper = *blobstreamkeeper.NewKeeper(
 		appCodec,
@@ -484,11 +483,27 @@ func (app *App) EndBlocker(ctx sdk.Context, req abci.RequestEndBlock) abci.Respo
 			}
 		}
 		// from v2 to v3 and onwards we use a signaling mechanism
-	} else if shouldUpgrade, newVersion := app.SignalKeeper.ShouldUpgrade(ctx); shouldUpgrade {
-		// Version changes must be increasing. Downgrades are not permitted
-		if newVersion > currentVersion {
-			app.BaseApp.Logger().Info("upgrading app version", "current version", currentVersion, "new version", newVersion)
-			app.SetAppVersion(ctx, newVersion)
+	} else if shouldUpgrade, upgrade := app.SignalKeeper.ShouldUpgrade(ctx); shouldUpgrade {
+		// Version changes must be increasing. Downgrades are not permitted.
+		if upgrade.AppVersion > currentVersion {
+			app.BaseApp.Logger().Info("upgrading app version", "current version", currentVersion, "new version", upgrade.AppVersion)
+
+			if currentVersion == v3 { // v3 -> v4 needs to schedule an upgrade
+				plan := upgradetypes.Plan{
+					Name:   fmt.Sprintf("v%d", upgrade.AppVersion),
+					Height: upgrade.UpgradeHeight + 1, // the block after the upgrade height executes the upgrade.
+				}
+
+				if err := app.UpgradeKeeper.ScheduleUpgrade(ctx, plan); err != nil {
+					panic(err)
+				}
+
+				if err := app.UpgradeKeeper.DumpUpgradeInfoToDisk(plan.Height, plan); err != nil {
+					panic(err)
+				}
+			}
+
+			app.SetAppVersion(ctx, upgrade.AppVersion)
 			app.SignalKeeper.ResetTally(ctx)
 		}
 	}
@@ -500,6 +515,11 @@ func (app *App) EndBlocker(ctx sdk.Context, req abci.RequestEndBlock) abci.Respo
 // migrateCommitStore tells the baseapp during a version upgrade, which stores to add and which
 // stores to remove
 func (app *App) migrateCommitStore(fromVersion, toVersion uint64) (baseapp.StoreMigrations, error) {
+	// migration from v3 -> v4 happens in the upgrade module
+	if toVersion == appv3.NextVersion {
+		return baseapp.StoreMigrations{}, nil
+	}
+
 	oldStoreKeys := app.keyVersions[fromVersion]
 	newStoreKeys := app.keyVersions[toVersion]
 	result := baseapp.StoreMigrations{
@@ -522,6 +542,11 @@ func (app *App) migrateCommitStore(fromVersion, toVersion uint64) (baseapp.Store
 // migrateModules performs migrations on existing modules that have registered migrations
 // between versions and initializes the state of new modules for the specified app version.
 func (app *App) migrateModules(ctx sdk.Context, fromVersion, toVersion uint64) error {
+	// migration from v3 -> v4 happens in the upgrade module
+	if toVersion == appv3.NextVersion {
+		return nil
+	}
+
 	return app.manager.RunMigrations(ctx, app.configurator, fromVersion, toVersion)
 }
 
diff --git a/app/modules.go b/app/modules.go
index ecdafbd2..ef516ef3 100644
--- a/app/modules.go
+++ b/app/modules.go
@@ -225,6 +225,7 @@ func (app *App) setModuleOrder() {
 	)
 
 	app.manager.SetOrderEndBlockers(
+		upgradetypes.ModuleName,
 		crisistypes.ModuleName,
 		govtypes.ModuleName,
 		stakingtypes.ModuleName,
diff --git a/docs/release-notes/pending-release.md b/docs/release-notes/pending-release.md
index c7e9198b..c3e0cf9d 100644
--- a/docs/release-notes/pending-release.md
+++ b/docs/release-notes/pending-release.md
@@ -4,4 +4,6 @@
 
 ### Node Operators (v4.0.0)
 
+- Node operators must upgrade to the latest release of celestia-app (e.g. v4.x.x) prior to signalling for the v4 upgrade. This release will run the latest v3.x.x celestia-app via the multiplexer prior to the v4 activation height. The latest v3.x.x release contains a reduction to the upgrade delay height on testnets. See <https://github.com/celestiaorg/celestia-app/pull/4414>.
+
 ### Library Consumers (v4.0.0)
diff --git a/go.mod b/go.mod
index b46857fd..427cf5cf 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	cosmossdk.io/math v1.5.0
 	github.com/celestiaorg/blobstream-contracts/v3 v3.1.0
 	github.com/celestiaorg/go-square v1.1.1
-	github.com/celestiaorg/go-square/v2 v2.1.0
+	github.com/celestiaorg/go-square/v2 v2.2.0
 	github.com/celestiaorg/knuu v0.16.3
 	github.com/celestiaorg/nmt v0.23.0
 	github.com/celestiaorg/rsmt2d v0.14.0
diff --git a/go.sum b/go.sum
index 95c6d5a5..33e462af 100644
--- a/go.sum
+++ b/go.sum
@@ -324,8 +324,8 @@ github.com/celestiaorg/cosmos-sdk v1.27.0-sdk-v0.46.16 h1:qxWiGrDEcg4FzVTpIXU/v3
 github.com/celestiaorg/cosmos-sdk v1.27.0-sdk-v0.46.16/go.mod h1:W30mNt3+2l516HVR8Gt9+Gf4qOrWC9/x18MTEx2GljE=
 github.com/celestiaorg/go-square v1.1.1 h1:Cy3p8WVspVcyOqHM8BWFuuYPwMitO1pYGe+ImILFZRA=
 github.com/celestiaorg/go-square v1.1.1/go.mod h1:1EXMErhDrWJM8B8V9hN7dqJ2kUTClfwdqMOmF9yQUa0=
-github.com/celestiaorg/go-square/v2 v2.1.0 h1:ECIvYEeHIWiIJGDCJxQNtzqm5DmnBly7XGhSpLsl+Lw=
-github.com/celestiaorg/go-square/v2 v2.1.0/go.mod h1:n3ztrh8CBjWOD6iWYMo3pPOlQIgzLK9yrnqMPcNo6g8=
+github.com/celestiaorg/go-square/v2 v2.2.0 h1:zJnUxCYc65S8FgUfVpyG/osDcsnjzo/JSXw/Uwn8zp4=
+github.com/celestiaorg/go-square/v2 v2.2.0/go.mod h1:j8kQUqJLYtcvCQMQV6QjEhUdaF7rBTXF74g8LbkR0Co=
 github.com/celestiaorg/ibc-go/v6 v6.2.5 h1:mUmZptulQGsGgyBqmdBeSCPQtMu0aFMJstgvKhUdqy4=
 github.com/celestiaorg/ibc-go/v6 v6.2.5/go.mod h1:XLsARy4Y7+GtAqzMcxNdlQf6lx+ti1e8KcMGv5NIK7A=
 github.com/celestiaorg/knuu v0.16.3 h1:ORmcBoSW+67iPKF0yIGKwWMefphk5hvy08KklmCT0aw=
diff --git a/pkg/appconsts/chain_ids.go b/pkg/appconsts/chain_ids.go
index ed0de976..30127a93 100644
--- a/pkg/appconsts/chain_ids.go
+++ b/pkg/appconsts/chain_ids.go
@@ -6,6 +6,8 @@ const (
 	MainnetChainID = "celestia"
 	// TestChainID is the chain ID used for testing.
 	TestChainID = "test"
+	// LocalDevnetChainID is another chain ID used for testing.
+	LocalDevnetChainID = "local_devnet"
 )
 
 var PublicNetworks = []string{ArabicaChainID, MochaChainID, MainnetChainID}
diff --git a/pkg/appconsts/v3/app_consts.go b/pkg/appconsts/v3/app_consts.go
index e5b660bf..d01d622e 100644
--- a/pkg/appconsts/v3/app_consts.go
+++ b/pkg/appconsts/v3/app_consts.go
@@ -4,6 +4,7 @@ import "time"
 
 const (
 	Version              uint64 = 3
+	NextVersion          uint64 = 4
 	SquareSizeUpperBound int    = 128
 	SubtreeRootThreshold int    = 64
 	TxSizeCostPerByte    uint64 = 10
diff --git a/pkg/appconsts/versioned_consts.go b/pkg/appconsts/versioned_consts.go
index 0ddbe16f..c75efd36 100644
--- a/pkg/appconsts/versioned_consts.go
+++ b/pkg/appconsts/versioned_consts.go
@@ -11,6 +11,9 @@ import (
 
 const (
 	LatestVersion = v3.Version
+	// numBlocksPerDay is the number of blocks in a day assuming a block
+	// interval of 6 seconds. Equivalent to 14,400 blocks.
+	numBlocksPerDay = int64(24 * 60 * 60 / 6)
 )
 
 // SubtreeRootThreshold works as a target upper bound for the number of subtree
@@ -78,12 +81,15 @@ func GetTimeoutCommit(v uint64) time.Duration {
 	}
 }
 
-// UpgradeHeightDelay returns the delay in blocks after a quorum has been reached that the chain should upgrade to the new version.
-func UpgradeHeightDelay(chainID string, v uint64) int64 {
-	if chainID == TestChainID {
+// UpgradeHeightDelay returns the delay in blocks after a quorum has been
+// reached that the chain should upgrade to the new version. The version
+// argument should be the current application version, not the version after the
+// upgrade.
+func UpgradeHeightDelay(chainID string, version uint64) int64 {
+	if chainID == TestChainID || chainID == LocalDevnetChainID {
 		return 3
 	}
-	switch v {
+	switch version {
 	case v1.Version:
 		return v1.UpgradeHeightDelay
 	case v2.Version:
@@ -94,7 +100,16 @@ func UpgradeHeightDelay(chainID string, v uint64) int64 {
 			return v3.UpgradeHeightDelay
 		}
 		return v2.UpgradeHeightDelay
-	default:
-		return v3.UpgradeHeightDelay
+	case v3.Version:
+		switch chainID {
+		case ArabicaChainID:
+			return numBlocksPerDay
+		case MochaChainID:
+			return numBlocksPerDay * 2
+		case MainnetChainID:
+			return v3.UpgradeHeightDelay
+		}
 	}
+	// TODO: this should panic because this should never be invoked for v4+
+	return v3.UpgradeHeightDelay
 }
diff --git a/specs/src/shares.md b/specs/src/shares.md
index 59ed91c9..2c03619d 100644
--- a/specs/src/shares.md
+++ b/specs/src/shares.md
@@ -64,7 +64,7 @@ Continuation share in a sequence:
 
 ## Transaction Shares
 
-In order for clients to parse shares in the middle of a sequence without downloading antecedent shares, Celestia encodes additional metadata in the shares associated with reserved namespaces. At the time of writing this only applies to the [`TRANSACTION_NAMESPACE`](./namespace.md#reserved-namespaces) and [`PAY_FOR_BLOB_NAMESPACE`](./namespace.md#reserved-namespaces). This share structure is often referred to as "compact shares" to differentiate from the share structure defined above for all shares. It conforms to the common [share format](#share-format) with one additional field, the "reserved bytes" field, which is described below:
+Transaction shares use share version 0. In order for clients to parse shares in the middle of a sequence without downloading antecedent shares, Celestia encodes additional metadata in the shares associated with reserved namespaces. At the time of writing this only applies to the [`TRANSACTION_NAMESPACE`](./namespace.md#reserved-namespaces) and [`PAY_FOR_BLOB_NAMESPACE`](./namespace.md#reserved-namespaces). This share structure is often referred to as "compact shares" to differentiate from the share structure defined above for all shares. It conforms to the common [share format](#share-format) with one additional field, the "reserved bytes" field, which is described below:
 
 - Every transaction share includes [`SHARE_RESERVED_BYTES`](./consensus.md#constants) bytes that contain the index of the starting byte of the length of the [canonically serialized](./consensus.md#serialization) first transaction that starts in the share, or `0` if there is none, as a binary big endian `uint32`. Denoted by "reserved bytes" in the figure below. The [`SHARE_RESERVED_BYTES`](./consensus.md#constants) are placed immediately after the `SEQUENCE_BYTES` if this is the first share in a sequence or immediately after the `SHARE_INFO_BYTES` if this is a continuation share in a sequence.
 - The remaining [`SHARE_SIZE`](./consensus.md#constants)`-`[`NAMESPACE_SIZE`](./consensus.md#constants)`-`[`SHARE_INFO_BYTES`](./consensus.md#constants)`-`[`SEQUENCE_BYTES`](./consensus.md#constants)`-`[`SHARE_RESERVED_BYTES`](./consensus.md#constants) bytes (if first share) or [`SHARE_SIZE`](./consensus.md#constants)`-`[`NAMESPACE_SIZE`](./consensus.md#constants)`-`[`SHARE_INFO_BYTES`](./consensus.md#constants)`-`[`SHARE_RESERVED_BYTES`](./consensus.md#constants) bytes (if continuation share) are transaction or PayForBlob transaction data (denoted by "tx1" and "tx2" in the figure below). Each transaction or PayForBlob transaction is prefixed with a [varint](https://developers.google.com/protocol-buffers/docs/encoding) of the length of that unit (denoted by "len(tx1)" and "len(tx2)" in the figure below).
@@ -84,7 +84,7 @@ where reserved bytes would be `80` as a binary big endian `uint32` (`[0b00000000
 
 ## Padding
 
-Padding shares vary based on namespace but they conform to the [share format](#share-format) described above.
+Padding shares use share version 0 and conform to the [share format](#share-format) described above. There are multiple variants of padding shares that differ based on their namespace.
 
 - The first [`NAMESPACE_VERSION_SIZE`](./consensus.md#constants) bytes of a share's raw data is the namespace version of that share (initially, this will be `0`).
 - The next [`NAMESPACE_ID_SIZE`](./consensus.md#constants) bytes of a share's raw data is the namespace ID of that share. This varies based on the type of padding share.
@@ -109,7 +109,7 @@ Tail padding shares use the [`TAIL_PADDING_NAMESPACE`](./namespace.md#reserved-n
 
 ## Parity Share
 
-Parity shares use the [`PARITY_SHARE_NAMESPACE`](./namespace.md#reserved-namespaces). Parity shares are the output of the erasure coding step of the data square construction process. They occupy quadrants Q1, Q2, and Q3 of the extended data square and are used to reconstruct the original data square (Q0). Bytes carry no special meaning.
+Parity shares are the output of the erasure coding step of the data square construction process. They occupy quadrants Q1, Q2, and Q3 of the extended data square and are used to reconstruct the original data square (Q0). Parity shares do not conform to the [share format](#share-format) described above. In the square layout, parity shares do not have a significant namespace. When parity shares are used in NMTs, they are prefixed with the `PARITY_SHARE_NAMESPACE` to preserve the property that all shares are ordered by namespace.
 
 ## Share Splitting
 
diff --git a/x/blob/ante/ante.go b/x/blob/ante/ante.go
index 3c5249b2..1c9989f8 100644
--- a/x/blob/ante/ante.go
+++ b/x/blob/ante/ante.go
@@ -8,6 +8,7 @@ import (
 	"cosmossdk.io/errors"
 	sdk "github.com/cosmos/cosmos-sdk/types"
 	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
+	"github.com/cosmos/cosmos-sdk/x/authz"
 )
 
 // MinGasPFBDecorator helps to prevent a PFB from being included in a block
@@ -29,27 +30,62 @@ func (d MinGasPFBDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool
 		return next(ctx, tx, simulate)
 	}
 
-	var gasPerByte uint32
+	// Skip gas checks during genesis initialization
+	if ctx.BlockHeight() == 0 {
+		return next(ctx, tx, simulate)
+	}
+
+	gasPerByte := d.getGasPerByte(ctx)
 	txGas := ctx.GasMeter().GasRemaining()
-	for _, m := range tx.GetMsgs() {
-		// NOTE: here we assume only one PFB per transaction
-		if pfb, ok := m.(*types.MsgPayForBlobs); ok {
-			if gasPerByte == 0 {
-				if ctx.BlockHeader().Version.App <= v2.Version {
-					// lazily fetch the gas per byte param
-					gasPerByte = d.k.GasPerBlobByte(ctx)
-				} else {
-					gasPerByte = appconsts.GasPerBlobByte(ctx.BlockHeader().Version.App)
-				}
+	err := d.validatePFBHasEnoughGas(tx.GetMsgs(), gasPerByte, txGas)
+	if err != nil {
+		return ctx, err
+	}
+
+	return next(ctx, tx, simulate)
+}
+
+// validatePFBHasEnoughGas iterates through all the msgs and nested msgs to find
+// a MsgPayForBlobs. If found, it validates that the txGas is enough to pay for
+// the blobs.
+func (d MinGasPFBDecorator) validatePFBHasEnoughGas(msgs []sdk.Msg, gasPerByte uint32, txGas uint64) error {
+	for _, m := range msgs {
+		if execMsg, ok := m.(*authz.MsgExec); ok {
+			// Recursively look for PFBs in nested authz messages.
+			nestedMsgs, err := execMsg.GetMessages()
+			if err != nil {
+				return err
 			}
-			gasToConsume := pfb.Gas(gasPerByte)
-			if gasToConsume > txGas {
-				return ctx, errors.Wrapf(sdkerrors.ErrInsufficientFee, "not enough gas to pay for blobs (minimum: %d, got: %d)", gasToConsume, txGas)
+			err = d.validatePFBHasEnoughGas(nestedMsgs, gasPerByte, txGas)
+			if err != nil {
+				return err
+			}
+		}
+		if pfb, ok := m.(*types.MsgPayForBlobs); ok {
+			err := validateEnoughGas(pfb, gasPerByte, txGas)
+			if err != nil {
+				return err
 			}
 		}
 	}
+	return nil
+}
 
-	return next(ctx, tx, simulate)
+func (d MinGasPFBDecorator) getGasPerByte(ctx sdk.Context) uint32 {
+	if ctx.BlockHeader().Version.App <= v2.Version {
+		return d.k.GasPerBlobByte(ctx)
+	}
+	return appconsts.GasPerBlobByte(ctx.BlockHeader().Version.App)
+}
+
+// validateEnoughGas returns an error if the gas needed to pay for the blobs is
+// greater than the txGas.
+func validateEnoughGas(msg *types.MsgPayForBlobs, gasPerByte uint32, txGas uint64) error {
+	gasToConsume := msg.Gas(gasPerByte)
+	if gasToConsume > txGas {
+		return errors.Wrapf(sdkerrors.ErrInsufficientFee, "not enough gas to pay for blobs (minimum: %d, got: %d)", gasToConsume, txGas)
+	}
+	return nil
 }
 
 type BlobKeeper interface {
diff --git a/x/blob/ante/blob_share_decorator.go b/x/blob/ante/blob_share_decorator.go
index 4c0c0361..3bb19378 100644
--- a/x/blob/ante/blob_share_decorator.go
+++ b/x/blob/ante/blob_share_decorator.go
@@ -1,13 +1,15 @@
 package ante
 
 import (
+	"math"
+
+	"cosmossdk.io/errors"
 	"github.com/celestiaorg/celestia-app/v3/pkg/appconsts"
 	v1 "github.com/celestiaorg/celestia-app/v3/pkg/appconsts/v1"
 	blobtypes "github.com/celestiaorg/celestia-app/v3/x/blob/types"
 	"github.com/celestiaorg/go-square/v2/share"
-
-	"cosmossdk.io/errors"
 	sdk "github.com/cosmos/cosmos-sdk/types"
+	"github.com/cosmos/cosmos-sdk/x/authz"
 )
 
 // BlobShareDecorator helps to prevent a PFB from being included in a block but
@@ -33,16 +35,42 @@ func (d BlobShareDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool
 		return next(ctx, tx, simulate)
 	}
 
+	txBytes := ctx.TxBytes()
+	if len(txBytes) > math.MaxUint32 {
+		return ctx, errors.Wrapf(blobtypes.ErrBlobsTooLarge, "the tx size %d exceeds the max uint32", txBytes)
+	}
+	txSize := uint32(len(txBytes))
 	maxBlobShares := d.getMaxBlobShares(ctx)
-	for _, m := range tx.GetMsgs() {
+
+	err := d.validateMsgs(tx.GetMsgs(), txSize, maxBlobShares)
+	if err != nil {
+		return ctx, err
+	}
+
+	return next(ctx, tx, simulate)
+}
+
+func (d BlobShareDecorator) validateMsgs(msgs []sdk.Msg, txSize uint32, maxBlobShares int) error {
+	for _, m := range msgs {
+		if execMsg, ok := m.(*authz.MsgExec); ok {
+			// Recursively look for PFBs in nested authz messages.
+			nestedMsgs, err := execMsg.GetMessages()
+			if err != nil {
+				return err
+			}
+			err = d.validateMsgs(nestedMsgs, txSize, maxBlobShares)
+			if err != nil {
+				return err
+			}
+		}
+
 		if pfb, ok := m.(*blobtypes.MsgPayForBlobs); ok {
-			if sharesNeeded := getSharesNeeded(uint32(len(ctx.TxBytes())), pfb.BlobSizes); sharesNeeded > maxBlobShares {
-				return ctx, errors.Wrapf(blobtypes.ErrBlobsTooLarge, "the number of shares occupied by blobs in this MsgPayForBlobs %d exceeds the max number of shares available for blob data %d", sharesNeeded, maxBlobShares)
+			if sharesNeeded := getSharesNeeded(txSize, pfb.BlobSizes); sharesNeeded > maxBlobShares {
+				return errors.Wrapf(blobtypes.ErrBlobsTooLarge, "the number of shares occupied by blobs in this MsgPayForBlobs %d exceeds the max number of shares available for blob data %d", sharesNeeded, maxBlobShares)
 			}
 		}
 	}
-
-	return next(ctx, tx, simulate)
+	return nil
 }
 
 // getMaxBlobShares returns the max the number of shares available for blob data.
diff --git a/x/signal/keeper.go b/x/signal/keeper.go
index a53a5a8c..1e8435c8 100644
--- a/x/signal/keeper.go
+++ b/x/signal/keeper.go
@@ -199,19 +199,19 @@ func (k Keeper) GetVotingPowerThreshold(ctx sdk.Context) sdkmath.Int {
 }
 
 // ShouldUpgrade returns whether the signalling mechanism has concluded that the
-// network is ready to upgrade and the version to upgrade to. It returns false
-// and 0 if no version has reached quorum.
-func (k *Keeper) ShouldUpgrade(ctx sdk.Context) (isQuorumVersion bool, version uint64) {
+// network is ready to upgrade and the upgrade. It returns false
+// and an empty upgrade if no version has reached quorum.
+func (k *Keeper) ShouldUpgrade(ctx sdk.Context) (isQuorumVersion bool, upgrade types.Upgrade) {
 	upgrade, ok := k.getUpgrade(ctx)
 	if !ok {
-		return false, 0
+		return false, types.Upgrade{}
 	}
 
 	hasUpgradeHeightBeenReached := ctx.BlockHeight() >= upgrade.UpgradeHeight
 	if hasUpgradeHeightBeenReached {
-		return true, upgrade.AppVersion
+		return true, upgrade
 	}
-	return false, 0
+	return false, types.Upgrade{}
 }
 
 // ResetTally resets the tally after a version change. It iterates over the



Test patch:
diff --git a/pkg/appconsts/versioned_consts_test.go b/pkg/appconsts/versioned_consts_test.go
index 6b62a58d..65c542c9 100644
--- a/pkg/appconsts/versioned_consts_test.go
+++ b/pkg/appconsts/versioned_consts_test.go
@@ -113,23 +113,53 @@ func TestUpgradeHeightDelay(t *testing.T) {
 			expectedUpgradeHeightDelay: v3.UpgradeHeightDelay, // falls back to v3 because of arabica bug
 		},
 		{
-			name:                       "v3 upgrade delay",
-			chainID:                    "mocha-4",
-			version:                    3,
-			expectedUpgradeHeightDelay: v3.UpgradeHeightDelay,
+			name:                       "the upgrade delay for chainID 'test' should be 3 regardless of the version",
+			chainID:                    appconsts.TestChainID,
+			version:                    v3.Version,
+			expectedUpgradeHeightDelay: 3,
 		},
 		{
 			name:                       "the upgrade delay for chainID 'test' should be 3 regardless of the version",
 			chainID:                    appconsts.TestChainID,
+			version:                    4,
+			expectedUpgradeHeightDelay: 3,
+		},
+		{
+			name:                       "the upgrade delay for chainID 'local_devnet' should be 3 regardless of the version",
+			chainID:                    appconsts.LocalDevnetChainID,
 			version:                    3,
 			expectedUpgradeHeightDelay: 3,
 		},
 		{
-			name:                       "the upgrade delay for chainID 'test' should be 3 regardless of the version",
-			chainID:                    appconsts.TestChainID,
+			name:                       "the upgrade delay for chainID 'local_devnet' should be 3 regardless of the version",
+			chainID:                    appconsts.LocalDevnetChainID,
 			version:                    4,
 			expectedUpgradeHeightDelay: 3,
 		},
+		{
+			name:                       "v3 upgrade delay on arabica is 1 day",
+			chainID:                    appconsts.ArabicaChainID,
+			version:                    v3.Version,
+			expectedUpgradeHeightDelay: 14_400, // 1 day of 6 second blocks
+		},
+		{
+			name:                       "v3 upgrade delay on mocha is 2 days",
+			chainID:                    appconsts.MochaChainID,
+			version:                    v3.Version,
+			expectedUpgradeHeightDelay: 28_800, // 2 days of 6 second blocks
+		},
+		{
+			name:                       "v3 upgrade delay on mainnet is 7 days",
+			chainID:                    appconsts.MainnetChainID,
+			version:                    v3.Version,
+			expectedUpgradeHeightDelay: v3.UpgradeHeightDelay, // 7 days of 6 second blocks
+		},
+		{
+			name:                       "v4 upgrade delay on arabica is 7 days",
+			chainID:                    appconsts.ArabicaChainID,
+			version:                    4,
+			expectedUpgradeHeightDelay: v3.UpgradeHeightDelay, // TODO: this should panic
+		},
 	}
 
 	for _, tc := range tests {
diff --git a/x/blob/ante/ante_test.go b/x/blob/ante/ante_test.go
index 53805f33..a3e1713c 100644
--- a/x/blob/ante/ante_test.go
+++ b/x/blob/ante/ante_test.go
@@ -2,6 +2,7 @@ package ante_test
 
 import (
 	"fmt"
+	"math"
 	"testing"
 
 	"github.com/celestiaorg/celestia-app/v3/app"
@@ -12,6 +13,8 @@ import (
 	blob "github.com/celestiaorg/celestia-app/v3/x/blob/types"
 	"github.com/celestiaorg/go-square/v2/share"
 	sdk "github.com/cosmos/cosmos-sdk/types"
+	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
+	"github.com/cosmos/cosmos-sdk/x/authz"
 	"github.com/stretchr/testify/require"
 	tmproto "github.com/tendermint/tendermint/proto/tendermint/types"
 	"github.com/tendermint/tendermint/proto/tendermint/version"
@@ -124,6 +127,7 @@ func TestPFBAnteHandler(t *testing.T) {
 					Version: version.Consensus{
 						App: currentVersion,
 					},
+					Height: 1,
 				}, true, nil).WithGasMeter(sdk.NewGasMeter(uint64(tc.txGas(gasPerBlobByte)))).WithIsCheckTx(true)
 
 				ctx.GasMeter().ConsumeGas(tc.gasConsumed, "test")
@@ -141,6 +145,49 @@ func TestPFBAnteHandler(t *testing.T) {
 	}
 }
 
+// TestMinGasPFBDecoratorWithMsgExec tests that the MinGasPFBDecorator rejects a
+// MsgExec that contains a MsgExec with a MsgPayForBlob where the MsgPayForBlob
+// gas cost is greater than the tx's gas limit.
+func TestMinGasPFBDecoratorWithMsgExec(t *testing.T) {
+	anteHandler := ante.NewMinGasPFBDecorator(mockBlobKeeper{})
+	txConfig := encoding.MakeConfig(app.ModuleEncodingRegisters...).TxConfig
+
+	// Create a context with a gas meter with a high gas limit
+	gasLimit := uint64(100_000_000)
+	ctx := sdk.NewContext(nil, tmproto.Header{
+		Version: version.Consensus{
+			App: appconsts.LatestVersion,
+		},
+		Height: 1,
+	}, true, nil).WithGasMeter(sdk.NewGasMeter(gasLimit)).WithIsCheckTx(true)
+
+	// Build a tx with a MsgExec containing a MsgPayForBlobs with a huge gas cost
+	txBuilder := txConfig.NewTxBuilder()
+	msgExec := authz.NewMsgExec(sdk.AccAddress{}, []sdk.Msg{
+		&blob.MsgPayForBlobs{
+			Signer:    "celestia...",
+			BlobSizes: []uint32{uint32(math.MaxUint32)},
+		},
+	})
+	require.NoError(t, txBuilder.SetMsgs(&msgExec))
+	tx := txBuilder.GetTx()
+
+	// Run the ante handler
+	_, err := anteHandler.AnteHandle(ctx, tx, false, mockNext)
+	require.Error(t, err)
+	require.ErrorIs(t, err, sdkerrors.ErrInsufficientFee)
+
+	// Create a MsgExec that wraps a MsgExec that contains a MsgPayForBlobs with a huge gas cost
+	nestedMsgExec := authz.NewMsgExec(sdk.AccAddress{}, []sdk.Msg{&msgExec})
+	require.NoError(t, txBuilder.SetMsgs(&nestedMsgExec))
+	tx = txBuilder.GetTx()
+
+	// Run the ante handler
+	_, err = anteHandler.AnteHandle(ctx, tx, false, mockNext)
+	require.Error(t, err)
+	require.ErrorIs(t, err, sdkerrors.ErrInsufficientFee)
+}
+
 type mockBlobKeeper struct{}
 
 func (mockBlobKeeper) GasPerBlobByte(_ sdk.Context) uint32 {
diff --git a/x/blob/ante/blob_share_decorator_test.go b/x/blob/ante/blob_share_decorator_test.go
index e65dca6c..736e851e 100644
--- a/x/blob/ante/blob_share_decorator_test.go
+++ b/x/blob/ante/blob_share_decorator_test.go
@@ -1,10 +1,12 @@
 package ante_test
 
 import (
+	"math"
 	"testing"
 
 	"github.com/celestiaorg/celestia-app/v3/app"
 	"github.com/celestiaorg/celestia-app/v3/app/encoding"
+	"github.com/celestiaorg/celestia-app/v3/pkg/appconsts"
 	v1 "github.com/celestiaorg/celestia-app/v3/pkg/appconsts/v1"
 	v2 "github.com/celestiaorg/celestia-app/v3/pkg/appconsts/v2"
 	"github.com/celestiaorg/celestia-app/v3/pkg/user"
@@ -16,6 +18,7 @@ import (
 	"github.com/celestiaorg/go-square/v2/share"
 	blobtx "github.com/celestiaorg/go-square/v2/tx"
 	sdk "github.com/cosmos/cosmos-sdk/types"
+	"github.com/cosmos/cosmos-sdk/x/authz"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	tmrand "github.com/tendermint/tendermint/libs/rand"
@@ -166,6 +169,39 @@ func TestBlobShareDecorator(t *testing.T) {
 	}
 }
 
+func TestBlobShareDecoratorWithMsgExec(t *testing.T) {
+	decorator := ante.NewBlobShareDecorator(mockBlobKeeper{})
+	ctx := sdk.Context{}.
+		WithIsCheckTx(true).
+		WithBlockHeader(tmproto.Header{Version: version.Consensus{App: appconsts.LatestVersion}})
+
+	txConfig := encoding.MakeConfig(app.ModuleEncodingRegisters...).TxConfig
+	txBuilder := txConfig.NewTxBuilder()
+	// Create a tx with a MsgExec that contains a MsgPayForBlobs with a huge
+	// blob that can not fit in a square.
+	msgExec := authz.NewMsgExec(sdk.AccAddress{}, []sdk.Msg{
+		&blob.MsgPayForBlobs{
+			Signer:    "celestia...",
+			BlobSizes: []uint32{uint32(math.MaxUint32)},
+		},
+	})
+	require.NoError(t, txBuilder.SetMsgs(&msgExec))
+	tx := txBuilder.GetTx()
+
+	_, err := decorator.AnteHandle(ctx, tx, false, mockNext)
+	assert.Error(t, err)
+	assert.ErrorIs(t, err, blob.ErrBlobsTooLarge)
+
+	// Create a MsgExec that wraps a MsgExec that contains a MsgPayForBlobs with a huge
+	// blob that can not fit in a square.
+	nestedMsgExec := authz.NewMsgExec(sdk.AccAddress{}, []sdk.Msg{&msgExec})
+	require.NoError(t, txBuilder.SetMsgs(&nestedMsgExec))
+	tx = txBuilder.GetTx()
+	_, err = decorator.AnteHandle(ctx, tx, false, mockNext)
+	assert.Error(t, err)
+	assert.ErrorIs(t, err, blob.ErrBlobsTooLarge)
+}
+
 func mockNext(ctx sdk.Context, _ sdk.Tx, _ bool) (sdk.Context, error) {
 	return ctx, nil
 }
diff --git a/x/signal/integration_test.go b/x/signal/integration_test.go
index 378ff748..61a1724a 100644
--- a/x/signal/integration_test.go
+++ b/x/signal/integration_test.go
@@ -84,13 +84,13 @@ func TestUpgradeIntegration(t *testing.T) {
 	require.Error(t, err)
 	require.ErrorIs(t, err, types.ErrUpgradePending)
 
-	shouldUpgrade, version := app.SignalKeeper.ShouldUpgrade(ctx)
+	shouldUpgrade, upgrade := app.SignalKeeper.ShouldUpgrade(ctx)
 	require.False(t, shouldUpgrade)
-	require.EqualValues(t, 0, version)
+	require.EqualValues(t, 0, upgrade.AppVersion)
 
-	ctx = ctx.WithBlockHeight(ctx.BlockHeight() + appconsts.UpgradeHeightDelay(appconsts.TestChainID, version))
+	ctx = ctx.WithBlockHeight(ctx.BlockHeight() + appconsts.UpgradeHeightDelay(appconsts.TestChainID, upgrade.AppVersion))
 
-	shouldUpgrade, version = app.SignalKeeper.ShouldUpgrade(ctx)
+	shouldUpgrade, upgrade = app.SignalKeeper.ShouldUpgrade(ctx)
 	require.True(t, shouldUpgrade)
-	require.EqualValues(t, 3, version)
+	require.EqualValues(t, 3, upgrade.AppVersion)
 }
diff --git a/x/signal/keeper_test.go b/x/signal/keeper_test.go
index 2cefad5b..c7cde4bf 100644
--- a/x/signal/keeper_test.go
+++ b/x/signal/keeper_test.go
@@ -165,9 +165,9 @@ func TestTallyingLogic(t *testing.T) {
 
 	_, err = upgradeKeeper.TryUpgrade(goCtx, &types.MsgTryUpgrade{})
 	require.NoError(t, err)
-	shouldUpgrade, version := upgradeKeeper.ShouldUpgrade(ctx)
+	shouldUpgrade, upgrade := upgradeKeeper.ShouldUpgrade(ctx)
 	require.False(t, shouldUpgrade)
-	require.Equal(t, uint64(0), version)
+	require.Equal(t, uint64(0), upgrade.AppVersion)
 
 	// we now have 101/120
 	_, err = upgradeKeeper.SignalVersion(goCtx, &types.MsgSignalVersion{
@@ -179,15 +179,15 @@ func TestTallyingLogic(t *testing.T) {
 	_, err = upgradeKeeper.TryUpgrade(goCtx, &types.MsgTryUpgrade{})
 	require.NoError(t, err)
 
-	shouldUpgrade, version = upgradeKeeper.ShouldUpgrade(ctx)
+	shouldUpgrade, upgrade = upgradeKeeper.ShouldUpgrade(ctx)
 	require.False(t, shouldUpgrade) // should be false because upgrade height hasn't been reached.
-	require.Equal(t, uint64(0), version)
+	require.Equal(t, uint64(0), upgrade.AppVersion)
 
-	ctx = ctx.WithBlockHeight(ctx.BlockHeight() + appconsts.UpgradeHeightDelay(appconsts.TestChainID, version))
+	ctx = ctx.WithBlockHeight(ctx.BlockHeight() + appconsts.UpgradeHeightDelay(appconsts.TestChainID, upgrade.AppVersion))
 
-	shouldUpgrade, version = upgradeKeeper.ShouldUpgrade(ctx)
+	shouldUpgrade, upgrade = upgradeKeeper.ShouldUpgrade(ctx)
 	require.True(t, shouldUpgrade) // should be true because upgrade height has been reached.
-	require.Equal(t, v2.Version, version)
+	require.Equal(t, v2.Version, upgrade.AppVersion)
 
 	upgradeKeeper.ResetTally(ctx)
 
@@ -426,7 +426,7 @@ func TestGetUpgrade(t *testing.T) {
 		got, err := upgradeKeeper.GetUpgrade(ctx, &types.QueryGetUpgradeRequest{})
 		require.NoError(t, err)
 		assert.Equal(t, v2.Version, got.Upgrade.AppVersion)
-		assert.Equal(t, appconsts.UpgradeHeightDelay(appconsts.TestChainID, v2.Version), got.Upgrade.UpgradeHeight)
+		assert.Equal(t, appconsts.UpgradeHeightDelay(appconsts.TestChainID, v1.Version), got.Upgrade.UpgradeHeight)
 	})
 }