-
Notifications
You must be signed in to change notification settings - Fork 1
feat: blockstest and saetest.Wallet test helpers
#24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ee9c338
a83e33d
d06d0a8
b00e044
4ffdfe0
1b146b7
bf3f566
de291e4
eb954b2
30a332f
58e1f28
5a97618
341217f
471f0c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| // Copyright (C) 2025, Ava Labs, Inc. All rights reserved. | ||
| // See the file LICENSE for licensing terms. | ||
|
|
||
| // Package blockstest provides test helpers for constructing [Streaming | ||
| // Asynchronous Execution] (SAE) blocks. | ||
| // | ||
| // [Streaming Asynchronous Execution]: https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/194-streaming-asynchronous-execution | ||
| package blockstest | ||
|
|
||
| import ( | ||
| "math/big" | ||
| "slices" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/ava-labs/avalanchego/utils/logging" | ||
| "github.com/ava-labs/libevm/core" | ||
| "github.com/ava-labs/libevm/core/state" | ||
| "github.com/ava-labs/libevm/core/types" | ||
| "github.com/ava-labs/libevm/ethdb" | ||
| "github.com/ava-labs/libevm/libevm/options" | ||
| "github.com/ava-labs/libevm/params" | ||
| "github.com/ava-labs/libevm/triedb" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/ava-labs/strevm/blocks" | ||
| "github.com/ava-labs/strevm/gastime" | ||
| "github.com/ava-labs/strevm/saetest" | ||
| ) | ||
|
|
||
| // An EthBlockOption configures the default block properties created by | ||
| // [NewEthBlock]. | ||
| type EthBlockOption = options.Option[ethBlockProperties] | ||
|
|
||
| // NewEthBlock constructs a raw Ethereum block with the given arguments. | ||
| func NewEthBlock(parent *types.Block, txs types.Transactions, opts ...EthBlockOption) *types.Block { | ||
| props := ðBlockProperties{ | ||
| header: &types.Header{ | ||
| Number: new(big.Int).Add(parent.Number(), big.NewInt(1)), | ||
| ParentHash: parent.Hash(), | ||
| BaseFee: big.NewInt(0), | ||
| }, | ||
| } | ||
| props = options.ApplyTo(props, opts...) | ||
| return types.NewBlock(props.header, txs, nil, props.receipts, saetest.TrieHasher()) | ||
| } | ||
|
|
||
| type ethBlockProperties struct { | ||
| header *types.Header | ||
| receipts types.Receipts | ||
| } | ||
|
|
||
| // ModifyHeader returns an option to modify the [types.Header] constructed by | ||
| // [NewEthBlock]. It SHOULD NOT modify the `Number` and `ParentHash`, but MAY | ||
| // modify any other field. | ||
| func ModifyHeader(fn func(*types.Header)) EthBlockOption { | ||
| return options.Func[ethBlockProperties](func(p *ethBlockProperties) { | ||
| fn(p.header) | ||
| }) | ||
| } | ||
|
|
||
| // WithReceipts returns an option to set the receipts of a block constructed by | ||
| // [NewEthBlock]. | ||
| func WithReceipts(rs types.Receipts) EthBlockOption { | ||
| return options.Func[ethBlockProperties](func(p *ethBlockProperties) { | ||
| p.receipts = slices.Clone(rs) | ||
| }) | ||
| } | ||
|
|
||
| // NewBlock constructs an SAE block, wrapping the raw Ethereum block. | ||
| func NewBlock(tb testing.TB, eth *types.Block, parent, lastSettled *blocks.Block) *blocks.Block { | ||
| tb.Helper() | ||
| b, err := blocks.New(eth, parent, lastSettled, saetest.NewTBLogger(tb, logging.Warn)) | ||
| require.NoError(tb, err, "blocks.New()") | ||
| return b | ||
| } | ||
|
|
||
| // NewGenesis constructs a new [core.Genesis], writes it to the database, and | ||
| // returns wraps [core.Genesis.ToBlock] with [NewBlock]. It assumes a nil | ||
| // [triedb.Config] unless overridden by a [WithTrieDBConfig]. The block is | ||
| // marked as both executed and synchronous. | ||
| func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, alloc types.GenesisAlloc, opts ...GenesisOption) *blocks.Block { | ||
| tb.Helper() | ||
| conf := options.ApplyTo(&genesisConfig{}, opts...) | ||
|
|
||
| gen := &core.Genesis{ | ||
| Config: config, | ||
| Alloc: alloc, | ||
| } | ||
|
|
||
| tdb := state.NewDatabaseWithConfig(db, conf.tdbConfig).TrieDB() | ||
| _, hash, err := core.SetupGenesisBlock(db, tdb, gen) | ||
| require.NoError(tb, err, "core.SetupGenesisBlock()") | ||
| require.NoErrorf(tb, tdb.Commit(hash, true), "%T.Commit(core.SetupGenesisBlock(...))", tdb) | ||
|
|
||
| b := NewBlock(tb, gen.ToBlock(), nil, nil) | ||
| require.NoErrorf(tb, b.MarkExecuted(db, gastime.New(gen.Timestamp, 1, 0), time.Time{}, new(big.Int), nil, b.SettledStateRoot()), "%T.MarkExecuted()", b) | ||
| require.NoErrorf(tb, b.MarkSynchronous(), "%T.MarkSynchronous()", b) | ||
| return b | ||
| } | ||
|
|
||
| type genesisConfig struct { | ||
| tdbConfig *triedb.Config | ||
| } | ||
|
|
||
| // A GenesisOption configures [NewGenesis]. | ||
| type GenesisOption = options.Option[genesisConfig] | ||
|
|
||
| // WithTrieDBConfig override the [triedb.Config] used by [NewGenesis]. | ||
| func WithTrieDBConfig(tc *triedb.Config) GenesisOption { | ||
| return options.Func[genesisConfig](func(gc *genesisConfig) { | ||
| gc.tdbConfig = tc | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| // Copyright (C) 2025, Ava Labs, Inc. All rights reserved. | ||
| // See the file LICENSE for licensing terms. | ||
|
|
||
| package blockstest | ||
|
|
||
| import ( | ||
| "math/big" | ||
| "testing" | ||
|
|
||
| "github.com/ava-labs/libevm/common" | ||
| "github.com/ava-labs/libevm/consensus" | ||
| "github.com/ava-labs/libevm/core" | ||
| "github.com/ava-labs/libevm/core/rawdb" | ||
| "github.com/ava-labs/libevm/core/state" | ||
| "github.com/ava-labs/libevm/core/types" | ||
| "github.com/ava-labs/libevm/core/vm" | ||
| "github.com/ava-labs/libevm/params" | ||
| "github.com/holiman/uint256" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/ava-labs/strevm/saetest" | ||
| ) | ||
|
|
||
| func TestIntegration(t *testing.T) { | ||
| const ( | ||
| numAccounts = 2 | ||
| numBlocks = 3 | ||
| txsPerAccountPerBlock = 3 | ||
| ) | ||
|
|
||
| config := params.AllDevChainProtocolChanges | ||
| wallet := saetest.NewUNSAFEWallet(t, numAccounts, types.LatestSigner(config)) | ||
| alloc := saetest.MaxAllocFor(wallet.Addresses()...) | ||
|
|
||
| db := rawdb.NewMemoryDatabase() | ||
|
|
||
| // Although the point of SAE is to replace [core.BlockChain], it remains the | ||
| // ground truth for correct block and transaction processing. If we were to | ||
| // test [ChainBuilder] with an SAE executor, which is itself going to be | ||
| // tested with the builder, then we would have a circular argument for | ||
| // correctness. | ||
| bc, err := core.NewBlockChain( | ||
| db, nil, | ||
| &core.Genesis{ | ||
| Config: config, | ||
| Alloc: alloc, | ||
| }, | ||
| nil, engine{}, vm.Config{}, | ||
| func(*types.Header) bool { return true }, | ||
| nil, | ||
| ) | ||
| require.NoError(t, err, "core.NewBlockChain()") | ||
| stateProc := core.NewStateProcessor(config, bc, engine{}) | ||
|
|
||
| sdb, err := state.New(bc.Genesis().Root(), state.NewDatabase(db), nil) | ||
| require.NoError(t, err, "state.New(%T.Genesis().Root())", bc) | ||
|
|
||
| build := NewChainBuilder(NewBlock(t, bc.Genesis(), nil, nil)) | ||
| dest := common.Address{'d', 'e', 's', 't'} | ||
| for i := range numBlocks { | ||
| // Genesis is block 0 | ||
| blockNum := uint64(i + 1) //nolint:gosec // Known to not overflow | ||
|
|
||
| var txs types.Transactions | ||
| for range txsPerAccountPerBlock { | ||
| for i := range numAccounts { | ||
| tx := wallet.SetNonceAndSign(t, i, &types.LegacyTx{ | ||
| To: &dest, | ||
| Value: big.NewInt(1), | ||
| Gas: params.TxGas, | ||
| GasPrice: big.NewInt(1), | ||
| }) | ||
| txs = append(txs, tx) | ||
| } | ||
| } | ||
| b := build.NewBlock(t, txs, ModifyHeader(func(h *types.Header) { | ||
| h.GasLimit = 100e6 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The gas limit is an annoying thing to have to specify every time, what if you just made it "sufficiently large" so that it won't cause problems for you rest?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is only necessary when using a |
||
| })) | ||
|
|
||
| receipts, _, _, err := stateProc.Process(b.EthBlock(), sdb, *bc.GetVMConfig()) | ||
| require.NoError(t, err, "%T.Process(%T.NewBlock().EthBlock()...)", stateProc, build) | ||
| for _, r := range receipts { | ||
| assert.Equal(t, types.ReceiptStatusSuccessful, r.Status, "%T.Status", r) | ||
| assert.Equal(t, blockNum, r.BlockNumber.Uint64(), "%T.BlockNumber", r) | ||
| } | ||
| } | ||
|
|
||
| t.Run("balance_of_recipient", func(t *testing.T) { | ||
| bal := sdb.GetBalance(dest) | ||
| require.True(t, bal.IsUint64(), "%T.GetBalance(...).IsUint64()", sdb) | ||
| require.Equal(t, uint64(numAccounts*numBlocks*txsPerAccountPerBlock), bal.Uint64()) | ||
| }) | ||
| } | ||
|
|
||
| // engine is a fake [consensus.Engine], implementing the minimum number of | ||
| // methods to avoid a panic. | ||
| type engine struct { | ||
| consensus.Engine | ||
| } | ||
|
|
||
| func (engine) VerifyHeader(consensus.ChainHeaderReader, *types.Header) error { | ||
| return nil | ||
| } | ||
|
|
||
| func (engine) Author(*types.Header) (common.Address, error) { | ||
| return common.Address{'a', 'u', 't', 'h'}, nil | ||
| } | ||
|
|
||
| func (engine) Finalize(consensus.ChainHeaderReader, *types.Header, *state.StateDB, []*types.Transaction, []*types.Header, []*types.Withdrawal) { | ||
| } | ||
|
|
||
| func TestNewGenesis(t *testing.T) { | ||
| config := params.AllDevChainProtocolChanges | ||
| signer := types.LatestSigner(config) | ||
| wallet := saetest.NewUNSAFEWallet(t, 10, signer) | ||
| alloc := saetest.MaxAllocFor(wallet.Addresses()...) | ||
|
|
||
| db := rawdb.NewMemoryDatabase() | ||
| gen := NewGenesis(t, db, config, alloc) | ||
|
|
||
| assert.True(t, gen.Executed(), "genesis.Executed()") | ||
| assert.NoError(t, gen.WaitUntilSettled(t.Context()), "genesis.WaitUntilSettled()") | ||
| assert.Equal(t, gen.Hash(), gen.LastSettled().Hash(), "genesis.LastSettled().Hash() is self") | ||
|
|
||
| t.Run("alloc", func(t *testing.T) { | ||
| sdb, err := state.New(gen.SettledStateRoot(), state.NewDatabase(db), nil) | ||
| require.NoError(t, err, "state.New(genesis.SettledStateRoot())") | ||
| for i, addr := range wallet.Addresses() { | ||
| want := new(uint256.Int).SetAllOne() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could move this outside the loop, since it's shared
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I considered that, and in production I would, but I prioritised reducing cognitive load by co-locating the expected value with the assertion. |
||
| assert.Truef(t, sdb.GetBalance(addr).Eq(want), "%T.GetBalance(%T.Addresses()[%d]) is max uint256", sdb, wallet, i) | ||
| } | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| // Copyright (C) 2025, Ava Labs, Inc. All rights reserved. | ||
| // See the file LICENSE for licensing terms. | ||
|
|
||
| // Package blockstest provides test helpers for constructing [Streaming | ||
| // Asynchronous Execution] (SAE) blocks. | ||
| // | ||
| // [Streaming Asynchronous Execution]: https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/194-streaming-asynchronous-execution | ||
| package blockstest | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/ava-labs/libevm/core/types" | ||
|
|
||
| "github.com/ava-labs/strevm/blocks" | ||
| ) | ||
|
|
||
| // A ChainBuilder builds a chain of blocks, maintaining necessary invariants. | ||
| type ChainBuilder struct { | ||
| chain []*blocks.Block | ||
| } | ||
|
|
||
| // NewChainBuilder returns a new ChainBuilder starting from the provided block, | ||
| // which MUST NOT be nil. | ||
| func NewChainBuilder(genesis *blocks.Block) *ChainBuilder { | ||
| return &ChainBuilder{ | ||
| chain: []*blocks.Block{genesis}, | ||
| } | ||
| } | ||
|
|
||
| // NewBlock constructs and returns a new block in the chain. | ||
| func (cb *ChainBuilder) NewBlock(tb testing.TB, txs []*types.Transaction, opts ...EthBlockOption) *blocks.Block { | ||
| tb.Helper() | ||
| last := cb.Last() | ||
| eth := NewEthBlock(last.EthBlock(), txs, opts...) | ||
| cb.chain = append(cb.chain, NewBlock(tb, eth, last, nil)) // TODO(arr4n) support last-settled blocks | ||
| return cb.Last() | ||
| } | ||
|
|
||
| // Last returns the last block to be built by the builder, which MAY be the | ||
| // genesis block passed to the constructor. | ||
| func (cb *ChainBuilder) Last() *blocks.Block { | ||
| return cb.chain[len(cb.chain)-1] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.