Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3d52c12
pin the three keys only startInProcess reads (PLT-851)
bdchatham Aug 10, 2026
b52cde7
record the three start-flag key names
bdchatham Aug 10, 2026
5f83961
assert that GetConfig leaves PruningKeepEvery alone, and correct what…
bdchatham Aug 10, 2026
24409bd
note the rescue on the concurrency-workers divergence row
bdchatham Aug 10, 2026
07e212d
pin the min-retain-blocks fan-out, the one split with two live readers
bdchatham Aug 10, 2026
a8e8138
test: pin the minimum-gas-prices separator contradiction
bdchatham Aug 10, 2026
b0a90e8
test: record that ValidateBasic is reported, not enforced
bdchatham Aug 10, 2026
ab814db
docs: scope the eleven-key split count and state what it costs
bdchatham Aug 10, 2026
4d24ce5
test: hold global-labels to its pairs, and record the shape that panics
bdchatham Aug 10, 2026
0bd0354
test: refuse a key set viper.Set cannot hold both halves of
bdchatham Aug 10, 2026
e335772
test: make the min-retain-blocks fan-out assertions able to fail
bdchatham Aug 10, 2026
26e794e
test: correct four claims review found overstated or wrong
bdchatham Aug 10, 2026
0957260
test: say what each min-retain assertion catches, and how to read a p…
bdchatham Aug 10, 2026
976af9f
test: make three assertions match the claims their comments make
bdchatham Aug 10, 2026
7f20dcd
test: close the case-sensitivity hole in the nesting guard
bdchatham Aug 11, 2026
8f98ada
test: fold a duplicate away, close a wrapping guard, and narrow two c…
bdchatham Aug 11, 2026
99f095f
test: catch two keys that are one key to viper, not just nesting
bdchatham Aug 11, 2026
314e0a4
test: stop the collision guard rejecting a legitimate override, fold …
bdchatham Aug 11, 2026
06fc6bd
test: correct a recorded safety property that was false
bdchatham Aug 11, 2026
b307f62
test: stop recording an architecture-dependent value as a literal
bdchatham Aug 11, 2026
da9ad2d
test: stop the prose promising a pin the file no longer has
bdchatham Aug 11, 2026
7ab5144
test: model viper's normalization one way, and fold a duplicated cons…
bdchatham Aug 11, 2026
fcba1db
test: reattach two doc comments to the declarations they describe
bdchatham Aug 11, 2026
658117c
test: correct three claims and make one failure report once
bdchatham Aug 11, 2026
67edc3f
test: put the saturating return where the comment becomes true
bdchatham Aug 11, 2026
b73c14e
test: read the start command seid ships, and unmerge a doc comment again
bdchatham Aug 11, 2026
6052faf
test: record the operator spelling of the key both retention readers …
bdchatham Aug 11, 2026
0ccef23
test: rename the fuzz-less file, and say what was deferred rather tha…
bdchatham Aug 11, 2026
899041c
test: fix a conversion and a name the pre-push gates caught
bdchatham Aug 11, 2026
b616cf4
test: one resolution of the shipped start command, and unfuse a parag…
bdchatham Aug 11, 2026
5c14a84
docs: collect the gaps this suite discloses but could close
bdchatham Aug 11, 2026
8e2d94c
docs: scope the saturated-KeepRecent risk to the litt backend
bdchatham Aug 11, 2026
1a62a1e
Merge branch 'main' into test/plt-851-live-node-config-reads
bdchatham Aug 11, 2026
63eef27
docs: move the fan-out architecture out of the test file's header
bdchatham Aug 11, 2026
fc72b85
docs: give the other two file headers the same treatment
bdchatham Aug 11, 2026
3f70e6a
Merge branch 'main' into test/plt-851-live-node-config-reads
bdchatham Aug 12, 2026
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
254 changes: 254 additions & 0 deletions app/receipt_store_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package app
Comment thread
bdchatham marked this conversation as resolved.

import (
"fmt"
"math"
"testing"

"github.com/spf13/cast"

"github.com/sei-protocol/sei-chain/app/params"
"github.com/sei-protocol/sei-chain/sei-cosmos/server"
srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config"
"github.com/sei-protocol/sei-chain/testutil/configtest"
)

// Tests for min-retain-blocks, the only key in this tree that two live consumers read. One
// operator value becomes both a Tendermint block-retention height and an EVM receipt retention
// window, through different casts.
//
// testutil/configtest/AGENTS.md holds the fan-out's architecture and the two gaps it leaves open,
// including which receipt-store backend survives a saturated retention window by an ordinary bound
// and which survives by an accident nothing here pins.

// minRetainBlocksFanOut is what one operator value becomes on each side.
Comment thread
bdchatham marked this conversation as resolved.
var minRetainBlocksFanOut = []struct {
Comment thread
bdchatham marked this conversation as resolved.
// raw is an any because the input type is part of the case. Both readers cast whatever
// appOpts.Get returns, and which Go type that is depends on the layer that set the key: the env
// layer and a quoted app.toml entry hand them a string, an unquoted integer decodes to int64,
// and an unquoted float literal decodes to float64. Only the float case makes the two casts
// disagree with the receipt side positive, so a table of strings could not express it.
raw any
receipt int // app/receipt_store_config.go:27, through cast.ToInt. Not read when saturates.
// block records what cast.ToUint64 produces, held against a number rather than a second call to
// the same function, since an assertion comparing a call to itself passes for any reader.
//
// It and the saturates rows assert spf13/cast and Go's float-to-int conversion rather than
// anything in this tree, so a cast bump can redden this package for a reason unrelated to
// configuration resolution, reported as a message about receipt retention.
block uint64 // cmd/seid/cmd/root.go:297, through cast.ToUint64
// saturates marks a row whose receipt cast Go leaves implementation-defined, so no literal is a
// correct prediction and the property is asserted in place of the value. Same reason a
// NumCPU-derived default is recorded as a DerivedDefault rather than a number.
saturates bool
}{
{"0", 0, 0, false}, // keep everything, both sides
{"100000", 100000, 100000, false}, // the ordinary case: one value, two policies
{"200000", 200000, 200000, false}, // a second ordinary value, same class
{"-5", -5, 0, false}, // ToInt keeps the negative, ToUint64 floors
{"9223372036854775808", 0, 9223372036854775808, false}, // past int64: ToInt floors, ToUint64 keeps
{"18446744073709551615", 0, 18446744073709551615, false}, // the uint64 ceiling, same shape
{"not-a-number", 0, 0, false}, // both floor, so both keep everything
// A leading sign is where the two string casts could most plausibly part company, because ParseInt
// takes a sign prefix. cast strips a leading + before ParseUint sees it, so they agree, and the row
// exists so that stops being something a reader takes on trust.
{"+5", 5, 5, false},
Comment thread
bdchatham marked this conversation as resolved.
// Unquoted in app.toml, so the decode hands both readers the operator's own type.
{int64(100000), 100000, 100000, false},
// The one shape where the two casts can disagree with the receipt side positive. Reachable as
// min-retain-blocks = 1e19, which decodes to float64. Go leaves a float-to-int conversion whose
// result the target cannot represent implementation-defined, and the two architectures the fleet
// ships take it differently: amd64 lowers it to a bare CVTTSD2SQ and gets the x86 indefinite value,
// MinInt64, while arm64 lowers it to FCVTZS and saturates to MaxInt64. So the receipt column is a
// property here rather than a number. The block column is not affected: uint64 of a float below
// 2^64 is the exact magnitude on both.
{float64(1e19), 0, 10000000000000000000, true},
// Just above 2^63, so the same conversion from a value an operator could plausibly mistype.
{float64(9.3e18), 0, 9300000000000000000, true},
}

// TestMinRetainBlocksFanOutNeverResolvesReceiptsToAPruningWindow asserts that the receipt side never

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The name and this godoc overclaim what the loop asserts, and they contradict the test 100 lines below.

The rows {"100000", 100000, 100000, false} and {"200000", ...} resolve KeepRecent to a positive value, which is a pruning window — and the loop passes them, because the row.receipt >= 0 && uint64(row.receipt) == row.block check returns early on agreement. TestMinRetainBlocksFullNodeModeCapsReceiptRetention then asserts the opposite in the same file: it requires KeepRecent > 0 for the default full node and its own doc says a full node "caps EVM receipt retention at a block count nobody set for that purpose."

What this test actually pins is narrower and worth saying precisely: the two casts never disagree with the receipt side left at a real positive window. Something like TestMinRetainBlocksFanOutNeverGivesReceiptsAWindowTheBlockSideDidNotAskFor, with the doc rewritten to match, would stop the two tests reading as contradictory.

// resolves to a window that would prune. It says the config layer resolves to a safe value, not that
// what happens to that value downstream is safe.
//
// The name says resolves rather than prunes because whether a resolved value goes on to expire
// anything is a sei-db property this layer cannot reach.
func TestMinRetainBlocksFanOutNeverResolvesReceiptsToAPruningWindow(t *testing.T) {
for _, row := range minRetainBlocksFanOut {
t.Run(fmt.Sprintf("%v(%T)", row.raw, row.raw), func(t *testing.T) {
receiptConfig, err := readReceiptStoreConfig(t.TempDir(), mapAppOpts{
server.FlagMinRetainBlocks: row.raw,
})
if err != nil {
t.Fatalf("readReceiptStoreConfig(%v): %v", row.raw, err)
}

// Where Go leaves the conversion implementation-defined, the property stands in for the
// value: the receipt side must be at or below zero, or the saturating positive extreme.
// Anything else positive is a real retention window and would prune.
if row.saturates {
if kr := receiptConfig.KeepRecent; kr > 0 && kr != math.MaxInt64 {
t.Errorf("min-retain-blocks=%v resolved the receipt store's KeepRecent to %d. Go "+
"leaves this conversion implementation-defined, so the two outcomes this suite "+
"accepts are at-or-below zero, which the KeepRecent>0 guard refuses, and MaxInt64, "+
"which sei-db is relied on to render harmless by overflow and which nothing here "+
"pins. A positive window that is neither is a real retention schedule an operator "+
"never set", row.raw, kr)
}
} else if receiptConfig.KeepRecent != row.receipt {
t.Errorf("min-retain-blocks=%v resolves the receipt store's KeepRecent to %d where this "+
"row predicts %d. The prediction describes app/receipt_store_config.go, so a reader "+
"you changed deliberately means updating the row and saying what a node now retains "+
"for receipts; a reader you did not change means the cast moved underneath it",
row.raw, receiptConfig.KeepRecent, row.receipt)
}
// The recording, against the cast the block side applies.
if got := cast.ToUint64(row.raw); got != row.block {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This is the other half of Codex's point, and the file already admits it ("a recording of the cast rather than a pin on root.go:297"). Two consequences worth weighing before this lands in app:

  1. The assertion's subject is spf13/cast, not this tree. A cast minor bump that changes any of the "+5", "9223372036854775808" or "18446744073709551615" results reddens the app package with a message about EVM receipt retention — a misattributed failure in the largest package in the repo.
  2. Nothing here fails if root.go:297 changes its key or cast, so the block column documents rather than protects.

Since the AGENTS.md gap entry already names the closing move (extract the app.New argument into a named constructor taking AppOpts), consider either doing that now — it is one small production change and would make this column a real pin — or dropping the column to a comment so a cast upgrade does not fail a test whose stated subject is configuration resolution.

t.Errorf("min-retain-blocks=%v casts to %d through cast.ToUint64, recorded as %d. The "+
"block column is a recording of the cast rather than a pin on root.go:297, so "+
"update it and say what a node now retains", row.raw, got, row.block)
}

// A saturating row is done here, and this sits above the agreement check rather than below
// it so that check never reads a receipt column holding no prediction. It stays below the
// block check, because the block column is a real prediction on these rows.
if row.saturates {
return
}

// Same number on both sides means the fan-out is a plain coupling and there is nothing to
// check. The sign test is part of that question rather than belt-and-braces: converting a
// negative receipt value to uint64 wraps it to the top of the range, so a row pairing a
// negative with a large block value would read as agreement and skip the check below.
if row.receipt >= 0 && uint64(row.receipt) == row.block {
return
}
// Positive is the whole test, because a positive KeepRecent is the one state that arms
// receipt expiry on either backend: pebbledb starts a pruner
// (sei-db/ledger_db/receipt/receipt_store.go:363) and litt sets a TTL
// (sei-db/ledger_db/receipt/litt_receipt_store.go:138). At or below zero, neither does.
if row.receipt > 0 {
t.Errorf("min-retain-blocks=%v gives %d for receipt retention and %d for block "+
"retention. The two disagree and the receipt side is a positive window that is not "+
"the saturation case, so a value the block side handled one way starts pruning EVM "+
"receipts on a schedule the operator never set", row.raw, row.receipt, row.block)
}
})
}
}

// minRetainBlocksKeyName records the operator-facing spelling of the key both retention readers share.
//
// A KeyName rather than a KeySpec, because it resolves into two different structs through two
// different casts and no single row could name a Path for it. The record exists because nothing else
// in the tree pins this constant's value: sei-cosmos/server/config's base_config record belongs to
// GetConfig, which reads the literal independently, so renaming server.FlagMinRetainBlocks moves the
// key for both live readers this file exists to hold still. That rename does fail today, where a test
// asks the start command to set a flag it no longer has, but it fails as "no such flag" rather than as
// an operator-facing key having moved, and it fails in another package.
//
// Spelled through the reader's own constant, which is the position the record exists for: a rename
// lands in this golden as a diff rather than only as a set failure somewhere else.
var minRetainBlocksKeyName = []configtest.KeyName{configtest.KeyName(server.FlagMinRetainBlocks)}

// TestMinRetainBlocksKeyNameMatchesTheRecordedName pins the spelling of the key above.
func TestMinRetainBlocksKeyNameMatchesTheRecordedName(t *testing.T) {
configtest.CheckKeyNames(t, "min_retain_blocks", nil, minRetainBlocksKeyName...)
}

// TestMinRetainBlocksFullNodeModeCapsReceiptRetention records the case where the fan-out bites, which
// is the default one.
//
// seid init defaults --mode to full, and setFullnodeTypeAppConfig sets min-retain-blocks to 100000 for
// Tendermint block pruning. The same key reaches the receipt store as KeepRecent, so a positive value
// arms receipt pruning: the default pebbledb backend starts a pruner from it
// (sei-db/ledger_db/receipt/receipt_store.go:176) and the litt backend applies a TTL and a read floor
// from it. So a default full node caps EVM receipt retention at a block count nobody set for that
// purpose, and the key's own documentation describes only block retention.
//
// An operator has no setting that keeps both. Block pruning at 100000 caps receipts, and keeping every
// receipt means min-retain-blocks of 0, which also stops pruning blocks. That is the coupling recorded
// here, and it is tracked as PLT-976.
//
// The value is read out of SetAppConfigByMode rather than written here, for the reason the archive test
// gives: a transcribed 100000 would hold whatever the mode did, so moving the mode off 100000 would
// change receipt retention on every full node with nothing reporting it.
func TestMinRetainBlocksFullNodeModeCapsReceiptRetention(t *testing.T) {
configtest.Isolate(t)

full := srvconfig.DefaultConfig()
params.SetAppConfigByMode(full, params.NodeModeFull)

if full.MinRetainBlocks != 100000 {
t.Fatalf("full mode now sets min-retain-blocks to %d rather than 100000. That value is also the "+
"receipt store's KeepRecent, so this moves how much EVM receipt history every default full "+
"node keeps, not only how many Tendermint blocks it retains. If the change is deliberate, "+
"say what receipt history a full node is now expected to serve", full.MinRetainBlocks)
}

receiptConfig, err := readReceiptStoreConfig(t.TempDir(), mapAppOpts{
server.FlagMinRetainBlocks: full.MinRetainBlocks,
})
if err != nil {
t.Fatalf("readReceiptStoreConfig: %v", err)
}
// The state that arms pruning, stated on its own so the name of this test stays true, and
// established before the comparison below so that comparison needs no unguarded conversion.
if receiptConfig.KeepRecent <= 0 {
Comment thread
bdchatham marked this conversation as resolved.
t.Errorf("full mode now leaves the receipt store's KeepRecent at %d, which is the no-pruning "+
"state. That is a better end state for receipt history and it changes what a full node "+
"keeps, so record what block retention it now gets instead", receiptConfig.KeepRecent)
}

// The mode's value reaching the reader intact. Compared as uint64 rather than converting the
// reader's int down, because the sign is already established above and widening cannot overflow
// where narrowing could.
if uint64(receiptConfig.KeepRecent) != full.MinRetainBlocks {
t.Errorf("full mode's min-retain-blocks of %d reached the receipt store as KeepRecent=%d. The "+
"fan-out is what this file records, so the two stopping agreeing is the thing to explain",
full.MinRetainBlocks, receiptConfig.KeepRecent)
}
}

// TestMinRetainBlocksArchiveModeKeepsBothRetentionsOpen records that the archive path is aligned.
//
// PLT-955 is an archive node pruning state history because its mode's state-store settings are
// discarded. This is the neighbouring question, asked and answered so nobody re-investigates it: the
// archive mode leaves min-retain-blocks at 0, which is keep-all for Tendermint blocks, and 0 through
// the receipt cast leaves KeepRecent at 0, which is no pruning. So the fan-out does not give archive
// a second history-loss path.
//
// Two things are checked and they catch different changes. The first assertion reads archive's value
// out of SetAppConfigByMode, so a mode that stops keeping every block fails there; that is the one
// that would have caught the change this test exists to notice, and it is why the value is sourced
// rather than transcribed. The second hands that value to the reader, which with the first assertion
// standing is always 0, so what it adds is narrower: it fails if readReceiptStoreConfig starts
// substituting a non-zero default for a zero key instead of passing it through.
func TestMinRetainBlocksArchiveModeKeepsBothRetentionsOpen(t *testing.T) {
Comment thread
bdchatham marked this conversation as resolved.
configtest.Isolate(t)

archive := srvconfig.DefaultConfig()
params.SetAppConfigByMode(archive, params.NodeModeArchive)

// Stated as its own assertion so a mode that stops keeping every block says so here, rather than
// through the receipt-side comparison below.
if archive.MinRetainBlocks != 0 {
Comment thread
bdchatham marked this conversation as resolved.
t.Fatalf("archive mode now sets min-retain-blocks to %d rather than 0. Tendermint prunes "+
"blocks on that schedule and the receipt store takes the same key, so an archive node "+
"keeps neither all blocks nor all receipts. If the mode changed deliberately, say what an "+
"archive node is now expected to retain", archive.MinRetainBlocks)
}

receiptConfig, err := readReceiptStoreConfig(t.TempDir(), mapAppOpts{
Comment thread
bdchatham marked this conversation as resolved.
server.FlagMinRetainBlocks: archive.MinRetainBlocks,
})
if err != nil {
t.Fatalf("readReceiptStoreConfig: %v", err)
}
if receiptConfig.KeepRecent != 0 {
t.Errorf("archive's min-retain-blocks of %d gave the receipt store KeepRecent=%d, where 0 is "+
"what leaves receipts unpruned. An archive node would now discard EVM receipts, which is "+
"the same class of loss as PLT-955 through a different key",
archive.MinRetainBlocks, receiptConfig.KeepRecent)
}
}
2 changes: 2 additions & 0 deletions app/testdata/min_retain_blocks.keys.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# keys with a target of their own
"min-retain-blocks"
1 change: 1 addition & 0 deletions app/testdata/wiring_coverage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ light_invariance CheckEveryRowHasADiscriminatingSeed
light_invariance CheckKeyNames
light_invariance CheckManifestCoversEveryField
light_invariance CheckRow
min_retain_blocks CheckKeyNames
state-commit CheckDefaults
state-commit CheckEveryRowHasADiscriminatingSeed
state-commit CheckKeyNames
Expand Down
Loading
Loading