From 26ccbc8733da3bf3b23d4ed09b01b4c1fd388a99 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 13:28:23 -0400 Subject: [PATCH 01/27] sae: Implement minimal C-Chain VM --- go.mod | 2 +- vms/saevm/cchain/BUILD.bazel | 99 ++++ vms/saevm/cchain/api.go | 382 ++++++++++++++++ vms/saevm/cchain/api_test.go | 72 +++ vms/saevm/cchain/hooks.go | 358 +++++++++++++++ vms/saevm/cchain/hooks_test.go | 133 ++++++ vms/saevm/cchain/tx/codec.go | 15 + vms/saevm/cchain/tx/export.go | 4 +- vms/saevm/cchain/tx/identifiers_test.go | 20 - vms/saevm/cchain/tx/import.go | 6 +- vms/saevm/cchain/tx/tx.go | 14 +- vms/saevm/cchain/tx/tx_test.go | 2 +- vms/saevm/cchain/tx/txtest/cmp.go | 15 + vms/saevm/cchain/tx/txtest/wallet.go | 72 ++- vms/saevm/cchain/vm.go | 200 ++++++++ vms/saevm/cchain/vm_test.go | 580 ++++++++++++++++++++++++ vms/saevm/sae/BUILD.bazel | 1 + vms/saevm/sae/consensus.go | 14 + vms/saevm/sae/rpc.go | 5 + vms/saevm/sae/vm_test.go | 15 +- vms/saevm/saetest/BUILD.bazel | 2 + vms/saevm/saetest/goleak.go | 27 ++ 22 files changed, 1987 insertions(+), 51 deletions(-) create mode 100644 vms/saevm/cchain/api.go create mode 100644 vms/saevm/cchain/api_test.go create mode 100644 vms/saevm/cchain/hooks.go create mode 100644 vms/saevm/cchain/hooks_test.go create mode 100644 vms/saevm/cchain/vm.go create mode 100644 vms/saevm/cchain/vm_test.go create mode 100644 vms/saevm/saetest/goleak.go diff --git a/go.mod b/go.mod index 5cc8c8d5502e..6ac06888abf6 100644 --- a/go.mod +++ b/go.mod @@ -94,7 +94,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/VictoriaMetrics/fastcache v1.12.1 // indirect - github.com/ava-labs/avalanchego/graft/evm v1.14.2 // indirect + github.com/ava-labs/avalanchego/graft/evm v1.14.2 github.com/ava-labs/firewood-go-ethhash/ffi v0.5.0 github.com/ava-labs/simplex v0.0.0-20260429081342-03ce910391ad github.com/beorn7/perks v1.0.1 // indirect diff --git a/vms/saevm/cchain/BUILD.bazel b/vms/saevm/cchain/BUILD.bazel index fa721a4fadad..61118a8900a5 100644 --- a/vms/saevm/cchain/BUILD.bazel +++ b/vms/saevm/cchain/BUILD.bazel @@ -1,3 +1,6 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("//.bazel:defs.bzl", "go_test") + # gazelle:default_visibility //vms/saevm/cchain:__subpackages__,//vms/saevm/cchain:external_consumers package(default_visibility = [ @@ -9,3 +12,99 @@ package_group( name = "external_consumers", packages = [], ) + +go_library( + name = "cchain", + srcs = [ + "api.go", + "hooks.go", + "vm.go", + ], + importpath = "github.com/ava-labs/avalanchego/vms/saevm/cchain", + deps = [ + "//api", + "//database", + "//database/prefixdb", + "//graft/coreth/core/extstate", + "//graft/coreth/plugin/evm/customtypes", + "//graft/evm/constants", + "//graft/evm/utils/rpc", + "//ids", + "//snow", + "//snow/engine/common", + "//snow/engine/snowman/block", + "//utils/constants", + "//utils/formatting", + "//utils/formatting/address", + "//utils/json", + "//utils/logging", + "//utils/rpc", + "//utils/set", + "//vms/components/avax", + "//vms/components/gas", + "//vms/evm/acp226", + "//vms/evm/database", + "//vms/saevm/cchain/state", + "//vms/saevm/cchain/tx", + "//vms/saevm/cchain/txpool", + "//vms/saevm/gastime", + "//vms/saevm/hook", + "//vms/saevm/sae", + "//vms/saevm/saedb", + "//vms/saevm/types", + "//x/blockdb", + "@com_github_ava_labs_libevm//common", + "@com_github_ava_labs_libevm//core", + "@com_github_ava_labs_libevm//core/rawdb", + "@com_github_ava_labs_libevm//core/state", + "@com_github_ava_labs_libevm//core/txpool/legacypool", + "@com_github_ava_labs_libevm//core/types", + "@com_github_ava_labs_libevm//libevm", + "@com_github_ava_labs_libevm//params", + "@com_github_ava_labs_libevm//trie", + "@com_github_ava_labs_libevm//triedb", + "@org_uber_go_zap//:zap", + ], +) + +go_test( + name = "cchain_test", + srcs = [ + "api_test.go", + "hooks_test.go", + "vm_test.go", + ], + embed = [":cchain"], + deps = [ + "//chains/atomic", + "//database/memdb", + "//database/prefixdb", + "//graft/coreth/plugin/evm", + "//graft/coreth/plugin/evm/customtypes", + "//ids", + "//snow", + "//snow/engine/common", + "//snow/engine/enginetest", + "//snow/engine/snowman/block", + "//snow/snowtest", + "//utils/crypto/secp256k1", + "//utils/logging", + "//utils/set", + "//vms/components/avax", + "//vms/saevm/blocks", + "//vms/saevm/cchain/tx", + "//vms/saevm/cchain/tx/txtest", + "//vms/saevm/params", + "//vms/saevm/saetest", + "//vms/secp256k1fx", + "@com_github_ava_labs_libevm//common", + "@com_github_ava_labs_libevm//core", + "@com_github_ava_labs_libevm//core/types", + "@com_github_ava_labs_libevm//libevm/options", + "@com_github_google_go_cmp//cmp", + "@com_github_holiman_uint256//:uint256", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@org_uber_go_goleak//:goleak", + ], +) diff --git a/vms/saevm/cchain/api.go b/vms/saevm/cchain/api.go new file mode 100644 index 000000000000..ab722eb02ee8 --- /dev/null +++ b/vms/saevm/cchain/api.go @@ -0,0 +1,382 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cchain + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/ava-labs/libevm/common" + "go.uber.org/zap" + + "github.com/ava-labs/avalanchego/api" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/formatting" + "github.com/ava-labs/avalanchego/utils/formatting/address" + "github.com/ava-labs/avalanchego/utils/json" + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/utils/rpc" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/state" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/txpool" +) + +// service is the server-side handler for the avax RPC API. +// +// The type is unexported but its methods are exported because gorilla RPC +// reflects on them to dispatch requests. +type service struct { + ctx *snow.Context + txpool *txpool.Txpool + state *state.State + + chainAlias string + hrp string + zeroAddress string +} + +func newService( + ctx *snow.Context, + txpool *txpool.Txpool, + state *state.State, +) (*service, error) { + chainAlias, err := ctx.BCLookup.PrimaryAlias(ctx.ChainID) + if err != nil { + return nil, err + } + + hrp := constants.GetHRP(ctx.NetworkID) + zeroAddress, err := address.Format(chainAlias, hrp, ids.ShortEmpty[:]) + if err != nil { + return nil, fmt.Errorf("formatting zero address: %w", err) + } + + return &service{ + ctx: ctx, + txpool: txpool, + state: state, + + chainAlias: chainAlias, + hrp: hrp, + zeroAddress: zeroAddress, + }, nil +} + +const maxGetUTXOsLimit = 1024 + +// terminal IDs are used as a sentinel to indicate the end of pagination. +var ( + termAddr = ids.ShortID(common.HexToAddress("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")) + termUTXOID = ids.ID(common.HexToHash("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")) +) + +func (s *service) GetUTXOs(_ *http.Request, a *api.GetUTXOsArgs, r *api.GetUTXOsReply) error { + s.ctx.Log.Debug("API called", + zap.String("service", "avax"), + zap.String("method", "getUTXOs"), + logging.UserStrings("addresses", a.Addresses), + zap.Stringer("encoding", a.Encoding), + ) + + sourceChainID, err := s.ctx.BCLookup.Lookup(a.SourceChain) + if err != nil { + return fmt.Errorf("parsing source chainID %q: %w", a.SourceChain, err) + } + + const maxAddrs = 1024 + if len(a.Addresses) > maxAddrs { + return fmt.Errorf("too many addresses: %d exceeds %d", len(a.Addresses), maxAddrs) + } + + addrs := make([][]byte, len(a.Addresses)) + for i, str := range a.Addresses { + addr, err := s.parseAddress(str) + if err != nil { + return fmt.Errorf("parsing address %q: %w", str, err) + } + addrs[i] = addr[:] + } + + // Set the response encoding here in case the client provided the terminal + // cursor. + r.Encoding = a.Encoding + + var ( + startAddr ids.ShortID + startUTXO ids.ID + ) + if a.StartIndex != (api.Index{}) { + startAddr, err = s.parseAddress(a.StartIndex.Address) + if err != nil { + return fmt.Errorf("parsing start address %q: %w", a.StartIndex.Address, err) + } + startUTXO, err = ids.FromString(a.StartIndex.UTXO) + if err != nil { + return fmt.Errorf("parsing start utxoID %q: %w", a.StartIndex.UTXO, err) + } + if startAddr == termAddr && startUTXO == termUTXOID { + // Client provided the terminal index, so there are no more results. + r.EndIndex.Address = s.zeroAddress + r.EndIndex.UTXO = ids.Empty.String() + return nil + } + } + + limit := a.Limit + if limit == 0 || limit > maxGetUTXOsLimit { + limit = maxGetUTXOsLimit + } + + // [atomic.SharedMemory.Indexed] iterates inclusively from startAddr and + // startUTXO, so we fetch one extra UTXO to return the next index. + utxos, nextAddr, nextUTXO, err := s.ctx.SharedMemory.Indexed( + sourceChainID, + addrs, + startAddr[:], + startUTXO[:], + int(limit)+1, + ) + if err != nil { + return fmt.Errorf("retrieving UTXOs: %w", err) + } + + var ( + endAddr ids.ShortID + endUTXO ids.ID + ) + if len(utxos) > int(limit) { + utxos = utxos[:limit] + endAddr, _ = ids.ToShortID(nextAddr) + endUTXO, _ = ids.ToID(nextUTXO) + } else { + endAddr = termAddr + endUTXO = termUTXOID + } + + r.UTXOs = make([]string, len(utxos)) + for i, utxo := range utxos { + r.UTXOs[i], err = formatting.Encode(a.Encoding, utxo) + if err != nil { + return fmt.Errorf("encoding utxo: %w", err) + } + } + + r.EndIndex.Address, err = address.Format(s.chainAlias, s.hrp, endAddr[:]) + if err != nil { + return fmt.Errorf("formatting address: %w", err) + } + r.EndIndex.UTXO = endUTXO.String() + r.NumFetched = json.Uint64(len(utxos)) + return nil +} + +// parseAddress parses str as either a human-readable address or cb58-encoded +// address. +func (s *service) parseAddress(str string) (ids.ShortID, error) { + if a, err := ids.ShortFromString(str); err == nil { + return a, nil + } + + chainAlias, hrp, addrBytes, err := address.Parse(str) + if err != nil { + return ids.ShortID{}, err + } + if hrp != s.hrp { + return ids.ShortID{}, fmt.Errorf("expected hrp %q but got %q", s.hrp, hrp) + } + chainID, err := s.ctx.BCLookup.Lookup(chainAlias) + if err != nil { + return ids.ShortID{}, err + } + if chainID != s.ctx.ChainID { + return ids.ShortID{}, fmt.Errorf("expected chainID %q but got %q", s.ctx.ChainID, chainID) + } + return ids.ToShortID(addrBytes) +} + +var errIssuingTx = errors.New("issuing tx") + +func (s *service) IssueTx(_ *http.Request, a *api.FormattedTx, r *api.JSONTxID) error { + s.ctx.Log.Debug("API called", + zap.String("service", "avax"), + zap.String("method", "issueTx"), + logging.UserString("tx", a.Tx), + zap.Stringer("encoding", a.Encoding), + ) + + txBytes, err := formatting.Decode(a.Encoding, a.Tx) + if err != nil { + return fmt.Errorf("decoding transaction: %w", err) + } + t, err := tx.Parse(txBytes) + if err != nil { + return fmt.Errorf("parsing transaction: %w", err) + } + + if err := s.txpool.Add(t); err != nil { + return fmt.Errorf("%w: %w", errIssuingTx, err) + } + + // TODO(StephenButtolph): Push gossip the tx. + + r.TxID = t.ID() + return nil +} + +// GetTxReply is the response returned by [service.GetAtomicTx]. +// +// It MUST be exported for gorilla RPC to publicly expose [service.GetAtomicTx]. +type GetTxReply struct { + api.FormattedTx + Height json.Uint64 `json:"blockHeight"` +} + +var errFetchingTx = errors.New("fetching tx") + +func (s *service) GetAtomicTx(_ *http.Request, a *api.GetTxArgs, r *GetTxReply) error { + s.ctx.Log.Debug("API called", + zap.String("service", "avax"), + zap.String("method", "getAtomicTx"), + zap.Stringer("txID", a.TxID), + zap.Stringer("encoding", a.Encoding), + ) + + t, height, err := s.state.GetTx(a.TxID) + if err != nil { + return fmt.Errorf("%w: %w", errFetchingTx, err) + } + txBytes, err := t.Bytes() + if err != nil { + return fmt.Errorf("marshalling tx: %w", err) + } + r.Tx, err = formatting.Encode(a.Encoding, txBytes) + if err != nil { + return fmt.Errorf("encoding tx: %w", err) + } + r.Encoding = a.Encoding + r.Height = json.Uint64(height) + return nil +} + +// Client interacts with the avax API served by the C-Chain. +type Client struct { + r rpc.EndpointRequester +} + +const ( + cchainHTTPPrefix = "/ext/" + constants.ChainAliasPrefix + "/C" + avaxHTTPPath = cchainHTTPPrefix + avaxHTTPExtensionPath +) + +// NewClient returns a [Client] that targets the C-Chain reachable at uri. +func NewClient(uri string) *Client { + return &Client{ + r: rpc.NewEndpointRequester(uri + avaxHTTPPath), + } +} + +// The most efficient encoding format is used for all calls by the client. +const clientEncoding = formatting.HexNC + +// GetUTXOs returns the UTXOs controlled by addrs that have been exported to +// the C-Chain from sourceChain. +// +// Responses are paginated via startAddr and startUTXOID. To fetch all UTXOs, +// the zero values can be passed on the first call and the returned +// (endAddr, endUTXOID) on each subsequent call until fewer than limit +// results are returned. +func (c *Client) GetUTXOs( + ctx context.Context, + addrs []ids.ShortID, + sourceChain ids.ID, + limit uint32, + startAddr ids.ShortID, + startUTXOID ids.ID, + options ...rpc.Option, +) ([]*avax.UTXO, ids.ShortID, ids.ID, error) { + res := &api.GetUTXOsReply{} + err := c.r.SendRequest(ctx, "avax.getUTXOs", &api.GetUTXOsArgs{ + Addresses: ids.ShortIDsToStrings(addrs), + SourceChain: sourceChain.String(), + Limit: json.Uint32(limit), + StartIndex: api.Index{ + Address: startAddr.String(), + UTXO: startUTXOID.String(), + }, + Encoding: clientEncoding, + }, res, options...) + if err != nil { + return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("sending request: %w", err) + } + + utxos := make([]*avax.UTXO, len(res.UTXOs)) + for i, raw := range res.UTXOs { + utxoBytes, err := formatting.Decode(res.Encoding, raw) + if err != nil { + return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("decoding utxo %d: %w", i, err) + } + utxos[i], err = tx.ParseUTXO(utxoBytes) + if err != nil { + return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("parsing utxo %d: %w", i, err) + } + } + endAddr, err := address.ParseToID(res.EndIndex.Address) + if err != nil { + return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("parsing end address: %w", err) + } + endUTXOID, err := ids.FromString(res.EndIndex.UTXO) + if err != nil { + return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("parsing end utxoID: %w", err) + } + return utxos, endAddr, endUTXOID, nil +} + +// IssueTx submits t to the txpool. +func (c *Client) IssueTx(ctx context.Context, t *tx.Tx, options ...rpc.Option) error { + txBytes, err := t.Bytes() + if err != nil { + return fmt.Errorf("marshalling tx: %w", err) + } + txStr, err := formatting.Encode(clientEncoding, txBytes) + if err != nil { + return fmt.Errorf("encoding tx: %w", err) + } + + err = c.r.SendRequest(ctx, "avax.issueTx", &api.FormattedTx{ + Tx: txStr, + Encoding: clientEncoding, + }, &api.JSONTxID{}, options...) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + return nil +} + +// GetTx returns an accepted cross-chain transaction along with the block height +// at which it was accepted. +func (c *Client) GetTx(ctx context.Context, txID ids.ID, options ...rpc.Option) (*tx.Tx, uint64, error) { + res := &GetTxReply{} + err := c.r.SendRequest(ctx, "avax.getAtomicTx", &api.GetTxArgs{ + TxID: txID, + Encoding: clientEncoding, + }, res, options...) + if err != nil { + return nil, 0, fmt.Errorf("sending request: %w", err) + } + + txBytes, err := formatting.Decode(res.Encoding, res.Tx) + if err != nil { + return nil, 0, fmt.Errorf("decoding tx: %w", err) + } + t, err := tx.Parse(txBytes) + if err != nil { + return nil, 0, fmt.Errorf("parsing tx: %w", err) + } + return t, uint64(res.Height), nil +} diff --git a/vms/saevm/cchain/api_test.go b/vms/saevm/cchain/api_test.go new file mode 100644 index 000000000000..238a1fff6ec4 --- /dev/null +++ b/vms/saevm/cchain/api_test.go @@ -0,0 +1,72 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cchain + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +// TestIssueTxRejectsInvalidTransaction asserts that [Client.IssueTx] surfaces +// an error from the transaction pool's verification pipeline. +func TestIssueTxRejectsInvalidTransaction(t *testing.T) { + sut := newSUT(t) + + sk := txtest.NewKey(t) // sk is NOT funded. + w := newWallet(sk, sut.snowCtx, sut.Client) + const ( + exportedAmount = 50 + txFee = 50 + ) + tx, _ := w.newExportTx( + t, + sut.snowCtx.XChainID, + []*secp256k1fx.TransferOutput{ + txtest.NewTransferOutput(exportedAmount, sk.Address()), + }, + txFee, + ) + + err := sut.IssueTx(t.Context(), tx) + require.ErrorContainsf(t, err, errIssuingTx.Error(), "%T.IssueTx()", sut.Client) +} + +// TestGetTxNotFound asserts that [Client.GetTx] surfaces an error when the +// requested tx has never been accepted. +func TestGetTxNotFound(t *testing.T) { + sut := newSUT(t) + + _, _, err := sut.GetTx(t.Context(), ids.GenerateTestID()) + require.ErrorContainsf(t, err, errFetchingTx.Error(), "%T.GetTx()", sut.Client) +} + +// TestGetUTXOsPagination asserts that walking [Client.GetUTXOs] yields each +// seeded UTXO exactly once. +func TestGetUTXOsPagination(t *testing.T) { + sut := newSUT(t) + + const numUTXOs = 5 + want := make([]*avax.UTXO, numUTXOs) + addr := txtest.NewKey(t).Address() + for i := range want { + want[i] = txtest.NewUTXO(uint64(i+1), sut.snowCtx.AVAXAssetID, addr) + } + sut.addUTXOs(t, snowtest.XChainID, want...) + + // pageSize=1 stresses the boundary behavior so any off-by-one in the cursor + // logic will surface here. + const pageSize = 1 + got := getUTXOs(t, sut.Client, snowtest.XChainID, pageSize, addr) + if diff := cmp.Diff(want, got, txtest.UTXOCmpOpt()); diff != "" { + t.Errorf("paginated UTXOs (-want +got):\n%s", diff) + } +} diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go new file mode 100644 index 000000000000..6da3a5d054a2 --- /dev/null +++ b/vms/saevm/cchain/hooks.go @@ -0,0 +1,358 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cchain + +import ( + "context" + "errors" + "fmt" + "iter" + "math/big" + "slices" + "time" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/libevm" + "github.com/ava-labs/libevm/trie" + "go.uber.org/zap" + + "github.com/ava-labs/avalanchego/graft/coreth/core/extstate" + "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/customtypes" + "github.com/ava-labs/avalanchego/graft/evm/constants" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/avalanchego/vms/evm/acp226" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/txpool" + "github.com/ava-labs/avalanchego/vms/saevm/gastime" + "github.com/ava-labs/avalanchego/vms/saevm/hook" + "github.com/ava-labs/avalanchego/x/blockdb" + + saestate "github.com/ava-labs/avalanchego/vms/saevm/cchain/state" + saetypes "github.com/ava-labs/avalanchego/vms/saevm/types" + ethparams "github.com/ava-labs/libevm/params" +) + +var _ hook.PointsG[*hookTx] = (*hooks)(nil) + +type hooks struct { + builder + state *saestate.State +} + +func newHooks( + ctx *snow.Context, + state *saestate.State, + pool *txpool.Pending, +) *hooks { + poolTxs := func(yield func(*hookTx) bool) { + for t := range pool.Iter() { + ht, err := newHookTx(t, ctx.AVAXAssetID) + if err != nil { + ctx.Log.Warn("failed to convert tx", + zap.Stringer("txID", t.ID()), + zap.Error(err), + ) + continue + } + if !yield(ht) { + return + } + } + } + return &hooks{ + builder{ + ctx, + time.Now, + func() iter.Seq[*hookTx] { + return poolTxs + }, + }, + state, + } +} + +func (h *hooks) BlockRebuilderFrom(b *types.Block) (hook.BlockBuilder[*hookTx], error) { + rawTxs, err := tx.ParseSlice(customtypes.BlockExtData(b)) + if err != nil { + return nil, fmt.Errorf("parsing txs: %w", err) + } + + txs := make([]*hookTx, len(rawTxs)) + for i, t := range rawTxs { + ht, err := newHookTx(t, h.ctx.AVAXAssetID) + if err != nil { + return nil, fmt.Errorf("converting tx %s (%d): %w", t.ID(), i, err) + } + txs[i] = ht + } + + now := h.BlockTime(b.Header()) + potentialTxs := slices.Values(txs) + return &builder{ + h.ctx, + func() time.Time { + return now + }, + func() iter.Seq[*hookTx] { + return potentialTxs + }, + }, nil +} + +func (h *hooks) ExecutionResultsDB(dataDir string) (saetypes.ExecutionResults, error) { + db, err := blockdb.New( + blockdb.DefaultConfig().WithDir(dataDir), + h.ctx.Log, + ) + if err != nil { + return saetypes.ExecutionResults{}, fmt.Errorf("creating execution results db: %w", err) + } + return saetypes.ExecutionResults{ + HeightIndex: db, + }, nil +} + +func (*hooks) GasConfigAfter(*types.Header) (gas.Gas, gastime.GasPriceConfig) { + // TODO(StephenButtolph): Extract parameters from the header. + return 1_000_000, gastime.GasPriceConfig{ + TargetToExcessScaling: 87, + MinPrice: 1, + } +} + +func (*hooks) SettledHeight(*types.Header) uint64 { + // TODO(StephenButtolph): Extract from the header. + return 0 +} + +func (*hooks) BlockTime(h *types.Header) time.Time { + // TODO(StephenButtolph): Extract milliseconds from the header. + return time.Unix(int64(h.Time), 0) //#nosec G115 -- Won't overflow for a few millennia +} + +func (h *hooks) EndOfBlockOps(b *types.Block) ([]hook.Op, error) { + txs, err := tx.ParseSlice(customtypes.BlockExtData(b)) + if err != nil { + return nil, fmt.Errorf("parsing txs: %w", err) + } + + ops := make([]hook.Op, len(txs)) + for i, t := range txs { + op, err := t.AsOp(h.ctx.AVAXAssetID) + if err != nil { + return nil, fmt.Errorf("converting tx %s (%d): %w", t.ID(), i, err) + } + ops[i] = op + } + return ops, nil +} + +func (*hooks) CanExecuteTransaction(common.Address, *common.Address, libevm.StateReader) error { + return nil +} + +func (*hooks) BeforeExecutingBlock(ethparams.Rules, *state.StateDB, *types.Block) error { + return nil +} + +func (h *hooks) AfterExecutingBlock(statedb *state.StateDB, b *types.Block, receipts types.Receipts) error { + txs, err := tx.ParseSlice(customtypes.BlockExtData(b)) + if err != nil { + return fmt.Errorf("parsing txs: %w", err) + } + + extstatedb := extstate.New(statedb) + for i, t := range txs { + if err := t.TransferNonAVAX(h.ctx.AVAXAssetID, extstatedb); err != nil { + return fmt.Errorf("transferring non-AVAX assets of tx %s (%d): %w", t.ID(), i, err) + } + } + + if err := h.state.Apply(b.NumberU64(), txs); err != nil { + return fmt.Errorf("applying cross-chain state: %w", err) + } + + // TODO(StephenButtolph): Persist produced warp messages. + _ = receipts + return nil +} + +var _ hook.BlockBuilder[*hookTx] = (*builder)(nil) + +type builder struct { + ctx *snow.Context + now func() time.Time + potentialTxs func() iter.Seq[*hookTx] +} + +func (b *builder) BuildHeader(parent *types.Header) (*types.Header, error) { + // TODO(StephenButtolph): Encode the ACP-176 target excess in the header. + // TODO(StephenButtolph): Encode the ACP-183 min price excess in the header. + // TODO(StephenButtolph): Enforce the minimum block time here. + return customtypes.WithHeaderExtra( + &types.Header{ + ParentHash: parent.Hash(), + Coinbase: constants.BlackholeAddr, + Difficulty: big.NewInt(1), + Number: new(big.Int).Add(parent.Number, common.Big1), + Time: uint64(b.now().Unix()), //#nosec G115 -- Known non-negative + BlobGasUsed: new(uint64), + ExcessBlobGas: new(uint64), + ParentBeaconRoot: new(common.Hash), + }, + &customtypes.HeaderExtra{ + // TODO(StephenButtolph): Prior to SAE, ExtDataGasUsed included the + // gas cost of the cross-chain transactions. This was used to + // advance the ACP-176 fee state. After SAE, the gas cost is + // accounted for in the executor with [hook.Op.Gas]. We can choose + // to keep populating this field, or just zero it out. + ExtDataGasUsed: big.NewInt(0), + // BlockGasCost has been set to 0 since the Granite upgrade. + BlockGasCost: big.NewInt(0), + // TODO(StephenButtolph): Encode the millisecond timestamp. + TimeMilliseconds: new(uint64), + // TODO(StephenButtolph): Encode the min-delay excess. + MinDelayExcess: new(acp226.DelayExcess), + }, + ), nil +} + +func (b *builder) PotentialEndOfBlockOps( + ctx context.Context, + header *types.Header, + settledHash common.Hash, + source saetypes.BlockSource, +) iter.Seq[*hookTx] { + seq := b.potentialTxs() + return func(yield func(*hookTx) bool) { + // Transactions are verified against the last executed state. So, we + // must also verify that they don't conflict with any transactions in + // blocks between the block we are building and the last executed block. + inputs, err := ancestorInputIDs(header, settledHash, source) + if err != nil { + b.ctx.Log.Error("failed to get ancestor input IDs", + zap.Error(err), + ) + return + } + + for t := range seq { + if inputs.Overlaps(t.inputs) { + b.ctx.Log.Debug("tx consumes previously consumed inputs", + zap.Stringer("txID", t.id), + ) + continue + } + if err := t.tx.SanityCheck(b.ctx); err != nil { + b.ctx.Log.Debug("tx failed sanity check", + zap.Stringer("txID", t.id), + zap.Error(err), + ) + continue + } + if err := t.tx.VerifyCredentials(b.ctx.SharedMemory); err != nil { + b.ctx.Log.Debug("tx failed credential verification", + zap.Stringer("txID", t.id), + zap.Error(err), + ) + continue + } + + if !yield(t) { + return + } + inputs.Union(t.inputs) + } + } +} + +var errMissingBlock = errors.New("missing block") + +// ancestorInputIDs returns the set of input IDs of all cross-chain transactions +// in the block range (h, settled), both exclusive. +func ancestorInputIDs(h *types.Header, settled common.Hash, source saetypes.BlockSource) (set.Set[ids.ID], error) { + var s set.Set[ids.ID] + for h.ParentHash != settled { + parentNumber := h.Number.Uint64() - 1 + p, ok := source(h.ParentHash, parentNumber) + if !ok { + return nil, fmt.Errorf("%w: %s (%d)", errMissingBlock, h.ParentHash, parentNumber) + } + + txs, err := tx.ParseSlice(customtypes.BlockExtData(p)) + if err != nil { + return nil, fmt.Errorf("parsing txs: %s (%d): %w", h.ParentHash, parentNumber, err) + } + for _, t := range txs { + s.Union(t.InputIDs()) + } + h = p.Header() + } + return s, nil +} + +func (*builder) BuildBlock( + header *types.Header, + blockCtx *block.Context, + ethTxs []*types.Transaction, + receipts []*types.Receipt, + avaxTxs []*hookTx, + settledHeight uint64, +) (*types.Block, error) { + txs := make([]*tx.Tx, len(avaxTxs)) + for i, avaxTx := range avaxTxs { + txs[i] = avaxTx.tx + } + extData, err := tx.MarshalSlice(txs) + if err != nil { + return nil, fmt.Errorf("marshalling txs: %w", err) + } + + // TODO(StephenButtolph): Encode warp predicate results in the header. + _ = blockCtx + // TODO(StephenButtolph): Encode settledHeight in the block. + _ = settledHeight + // TODO(StephenButtolph): Verify the extDataHash matches the hash of extData + // during parsing. + return customtypes.NewBlockWithExtData( + header, + ethTxs, + nil, // uncles + receipts, + trie.NewStackTrie(nil), + extData, + true, // update [customtypes.HeaderExtra.ExtDataHash] + ), nil +} + +var _ hook.Transaction = (*hookTx)(nil) + +// hookTx adapts a [tx.Tx] to the [hook.Transaction] interface. +type hookTx struct { + id ids.ID + tx *tx.Tx + inputs set.Set[ids.ID] + op hook.Op +} + +func newHookTx(t *tx.Tx, avaxAssetID ids.ID) (*hookTx, error) { + op, err := t.AsOp(avaxAssetID) + if err != nil { + return nil, err + } + return &hookTx{ + id: op.ID, + tx: t, + inputs: t.InputIDs(), + op: op, + }, nil +} + +func (t *hookTx) AsOp() hook.Op { return t.op } diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go new file mode 100644 index 000000000000..7aecdd31218a --- /dev/null +++ b/vms/saevm/cchain/hooks_test.go @@ -0,0 +1,133 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cchain + +import ( + "math/big" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/customtypes" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" + "github.com/ava-labs/avalanchego/vms/saevm/saetest" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" +) + +// newBlock returns a minimal [*types.Block] whose ExtData encodes txs and +// whose header is configured for ancestor traversal (parent hash + number). +func newBlock(tb testing.TB, number uint64, parent common.Hash, txs ...*tx.Tx) *types.Block { + tb.Helper() + + extData, err := tx.MarshalSlice(txs) + require.NoErrorf(tb, err, "tx.MarshalSlice(%d txs)", len(txs)) + + return customtypes.NewBlockWithExtData( + &types.Header{ + ParentHash: parent, + Number: new(big.Int).SetUint64(number), + }, + nil, // txs + nil, // uncles + nil, // receipts + saetest.TrieHasher(), + extData, + true, // setExtDataHash + ) +} + +func TestAncestorInputIDs(t *testing.T) { + w := newWallet(txtest.NewKey(t), snowtest.Context(t, snowtest.CChainID), nil) + export := func() *tx.Tx { + const ( + exportedAmount = 50 + txFee = 50 + ) + signed, _ := w.newExportTx( + t, + snowtest.XChainID, + []*secp256k1fx.TransferOutput{ + txtest.NewTransferOutput(exportedAmount, w.sk.Address()), + }, + txFee, + ) + return signed + } + + var ( + settled = common.Hash(ids.GenerateTestID()) + + tx1 = export() + block1 = newBlock(t, 1, settled, tx1) + tx2 = export() + block2 = newBlock(t, 2, block1.Hash(), tx2) + tx3 = export() + block3 = newBlock(t, 3, block2.Hash(), tx3) + block4 = newBlock(t, 4, block3.Hash()) + ) + + tests := []struct { + name string + header *types.Header + settled common.Hash + want set.Set[ids.ID] + wantErr error + }{ + { + name: "empty range", + header: block1.Header(), + settled: settled, + want: nil, + }, + { + name: "single ancestor", + header: block2.Header(), + settled: settled, + want: tx1.InputIDs(), + }, + { + name: "multiple ancestors", + header: block4.Header(), + settled: settled, + want: union(tx1.InputIDs(), tx2.InputIDs(), tx3.InputIDs()), + }, + { + name: "missing block", + header: block2.Header(), + settled: common.Hash(ids.GenerateTestID()), // never matches + wantErr: errMissingBlock, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + source := func(hash common.Hash, number uint64) (*types.Block, bool) { + for _, b := range []*types.Block{block1, block2, block3} { + if b.Hash() == hash && b.NumberU64() == number { + return b, true + } + } + return nil, false + } + + got, err := ancestorInputIDs(tt.header, tt.settled, source) + require.ErrorIs(t, err, tt.wantErr, "ancestorInputIDs()") + assert.Equal(t, tt.want, got, "ancestorInputIDs()") + }) + } +} + +func union[T comparable](sets ...set.Set[T]) set.Set[T] { + var out set.Set[T] + for _, s := range sets { + out.Union(s) + } + return out +} diff --git a/vms/saevm/cchain/tx/codec.go b/vms/saevm/cchain/tx/codec.go index ff1debd64b50..4031452b9255 100644 --- a/vms/saevm/cchain/tx/codec.go +++ b/vms/saevm/cchain/tx/codec.go @@ -9,6 +9,7 @@ import ( "github.com/ava-labs/avalanchego/codec" "github.com/ava-labs/avalanchego/codec/linearcodec" "github.com/ava-labs/avalanchego/utils/wrappers" + "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/secp256k1fx" ) @@ -73,3 +74,17 @@ func ParseSlice(b []byte) ([]*Tx, error) { } return txs, nil } + +// MarshalUTXO serializes an [avax.UTXO] to its canonical binary format. +func MarshalUTXO(utxo *avax.UTXO) ([]byte, error) { + return c.Marshal(codecVersion, utxo) +} + +// ParseUTXO deserializes an [avax.UTXO] from its canonical binary format. +func ParseUTXO(b []byte) (*avax.UTXO, error) { + utxo := new(avax.UTXO) + if _, err := c.Unmarshal(b, utxo); err != nil { + return nil, err + } + return utxo, nil +} diff --git a/vms/saevm/cchain/tx/export.go b/vms/saevm/cchain/tx/export.go index a6ae419ef2cd..9284d0b691b4 100644 --- a/vms/saevm/cchain/tx/export.go +++ b/vms/saevm/cchain/tx/export.go @@ -215,7 +215,7 @@ func (e *Export) asOp(avaxAssetID ids.ID) (op, error) { // Even if no AVAX is debited, non-AVAX inputs MUST increment the nonce. if in.AssetID == avaxAssetID { - amount := scaleAVAX(in.Amount) + amount := ScaleAVAX(in.Amount) if _, overflow := debit.Amount.AddOverflow(&debit.Amount, &amount); overflow { return op{}, fmt.Errorf("%w: for address %s", errOverflow, in.Address) } @@ -242,7 +242,7 @@ func (e *Export) atomicRequests(txID ids.ID) (ids.ID, *chainsatomic.Requests, er Out: out.Out, } - utxoBytes, err := c.Marshal(codecVersion, utxo) + utxoBytes, err := MarshalUTXO(utxo) if err != nil { return ids.ID{}, nil, err } diff --git a/vms/saevm/cchain/tx/identifiers_test.go b/vms/saevm/cchain/tx/identifiers_test.go index 310ffc1cfdd5..f2c41c7be8fc 100644 --- a/vms/saevm/cchain/tx/identifiers_test.go +++ b/vms/saevm/cchain/tx/identifiers_test.go @@ -3,22 +3,10 @@ package tx -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/ava-labs/avalanchego/vms/components/avax" -) - // These identifiers are all exported for usage by tx_test.go, which is compiled // in a separate package to allow for the usage of the txtest package. -const X2CRate = _x2cRate - var ( - ScaleAVAX = scaleAVAX - // tx errors: ErrWrongNetworkID = errWrongNetworkID ErrWrongChainID = errWrongChainID @@ -54,11 +42,3 @@ var ( ErrMismatchedAssetIDs = errMismatchedAssetIDs ErrVerifyingTransfer = errVerifyingTransfer ) - -func MarshalUTXO(tb testing.TB, utxo *avax.UTXO) []byte { - tb.Helper() - - b, err := c.Marshal(codecVersion, utxo) - require.NoError(tb, err, "%T.Marshal(%T)", c, utxo) - return b -} diff --git a/vms/saevm/cchain/tx/import.go b/vms/saevm/cchain/tx/import.go index 1489ff0ae7ce..52d0b84a18b2 100644 --- a/vms/saevm/cchain/tx/import.go +++ b/vms/saevm/cchain/tx/import.go @@ -180,8 +180,8 @@ func (i *Import) verifyCredentials(sm chainsatomic.SharedMemory, creds []Credent // includes signature verification. This is non-trivial, because // transactions frequently contain duplicate signatures, which are // currently being cached. - utxo := new(avax.UTXO) - if _, err := c.Unmarshal(utxoBytes[j], utxo); err != nil { + utxo, err := ParseUTXO(utxoBytes[j]) + if err != nil { return fmt.Errorf("%w (%d): %w", errUnmarshallingUTXO, j, err) } if utxo.Asset.ID != in.Asset.ID { @@ -217,7 +217,7 @@ func (i *Import) asOp(avaxAssetID ids.ID) (op, error) { var ( total = mint[out.Address] - amount = scaleAVAX(out.Amount) + amount = ScaleAVAX(out.Amount) ) if _, overflow := total.AddOverflow(&total, &amount); overflow { return op{}, fmt.Errorf("%w: for address %s", errOverflow, out.Address) diff --git a/vms/saevm/cchain/tx/tx.go b/vms/saevm/cchain/tx/tx.go index 60c38b5fd11e..d2b131d10b04 100644 --- a/vms/saevm/cchain/tx/tx.go +++ b/vms/saevm/cchain/tx/tx.go @@ -199,15 +199,15 @@ func gasUsed(t Unsigned) (gas.Gas, error) { return math.Add(intrinsicGas, dynamicGas) } -const _x2cRate = 1_000_000_000 +// X2CRate is the conversion rate between the smallest denomination on the +// X-Chain, 1 nAVAX, and the smallest denomination on the C-Chain, 1 aAVAX. +const X2CRate = 1_000_000_000 -// x2cRate is the conversion rate between the smallest denomination on the -// X-Chain, 1 nAVAX, and the smallest denomination on the C-Chain 1 aAVAX. -var x2cRate = uint256.NewInt(_x2cRate) +var x2cRate = uint256.NewInt(X2CRate) -// scaleAVAX converts an amount denominated in nAVAX into the C-Chain's aAVAX +// ScaleAVAX converts an amount denominated in nAVAX into the C-Chain's aAVAX // denomination. -func scaleAVAX(nAVAX uint64) uint256.Int { +func ScaleAVAX(nAVAX uint64) uint256.Int { var aAVAX uint256.Int aAVAX.SetUint64(nAVAX) aAVAX.Mul(&aAVAX, x2cRate) @@ -222,7 +222,7 @@ func gasPrice(cost uint64, gas gas.Gas) uint256.Int { var u uint256.Int u.SetUint64(uint64(gas)) - p := scaleAVAX(cost) + p := ScaleAVAX(cost) p.Div(&p, &u) return p } diff --git a/vms/saevm/cchain/tx/tx_test.go b/vms/saevm/cchain/tx/tx_test.go index ddefc6f59f45..c1fa3b2798c3 100644 --- a/vms/saevm/cchain/tx/tx_test.go +++ b/vms/saevm/cchain/tx/tx_test.go @@ -1774,7 +1774,7 @@ func TestVerifyCredentials(t *testing.T) { validInputID = validUTXOID.InputID() validUTXOs = []*chainsatomic.Element{{ Key: validInputID[:], - Value: MarshalUTXO(t, validUTXO), + Value: txtest.MustMarshalUTXO(t, validUTXO), }} validImportTx = func() *Tx { diff --git a/vms/saevm/cchain/tx/txtest/cmp.go b/vms/saevm/cchain/tx/txtest/cmp.go index 20839c19cf7b..c69fa368256e 100644 --- a/vms/saevm/cchain/tx/txtest/cmp.go +++ b/vms/saevm/cchain/tx/txtest/cmp.go @@ -23,3 +23,18 @@ func CmpOpt() cmp.Option { cmpopts.EquateEmpty(), }) } + +// UTXOCmpOpt returns a configuration for [cmp.Diff] to compare [avax.UTXO] +// instances or slices thereof. Slice order is not considered. +func UTXOCmpOpt() cmp.Option { + return cmp.Options{ + cmpopts.IgnoreUnexported( + avax.UTXOID{}, + secp256k1fx.OutputOwners{}, + ), + cmpopts.EquateEmpty(), + cmpopts.SortSlices(func(a, b *avax.UTXO) bool { + return a.InputID().Compare(b.InputID()) < 0 + }), + } +} diff --git a/vms/saevm/cchain/tx/txtest/wallet.go b/vms/saevm/cchain/tx/txtest/wallet.go index b39e0aadf6be..236c72fd50ce 100644 --- a/vms/saevm/cchain/tx/txtest/wallet.go +++ b/vms/saevm/cchain/tx/txtest/wallet.go @@ -8,18 +8,27 @@ import ( "github.com/stretchr/testify/require" - // Imported for [secp256k1fx.Credential] comment resolution. - _ "github.com/ava-labs/avalanchego/vms/secp256k1fx" - + "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/crypto/keychain" "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" + "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" ) // Signature can be used within a [secp256k1fx.Credential] to authorize a // transaction. type Signature = [secp256k1.SignatureLen]byte +// NewKey returns a freshly-generated [secp256k1.PrivateKey]. +func NewKey(tb testing.TB) *secp256k1.PrivateKey { + tb.Helper() + + sk, err := secp256k1.NewPrivateKey() + require.NoError(tb, err, "secp256k1.NewPrivateKey()") + return sk +} + // Sign signs u with s and returns the signature. func Sign(tb testing.TB, u tx.Unsigned, s keychain.Signer) Signature { tb.Helper() @@ -31,3 +40,60 @@ func Sign(tb testing.TB, u tx.Unsigned, s keychain.Signer) Signature { require.Lenf(tb, sig, len(Signature{}), "len(%T.Sign(%T))", s, u) return Signature(sig) } + +// MustMarshalUTXO returns the canonical binary format of utxo. +func MustMarshalUTXO(tb testing.TB, utxo *avax.UTXO) []byte { + tb.Helper() + + b, err := tx.MarshalUTXO(utxo) + require.NoError(tb, err, "tx.MarshalUTXO()") + return b +} + +// MustParseUTXO deserializes an [avax.UTXO] from its canonical binary format. +func MustParseUTXO(tb testing.TB, b []byte) *avax.UTXO { + tb.Helper() + + utxo, err := tx.ParseUTXO(b) + require.NoError(tb, err, "tx.ParseUTXO()") + return utxo +} + +// NewTransferOutput returns a single-owner [secp256k1fx.TransferOutput] with +// threshold 1 paying amt to addr. +func NewTransferOutput(amt uint64, addr ids.ShortID) *secp256k1fx.TransferOutput { + return &secp256k1fx.TransferOutput{ + Amt: amt, + OutputOwners: secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{addr}, + }, + } +} + +// NewUTXO returns a new [avax.UTXO] containing a single-owner output of amt of +// assetID held by addr. +func NewUTXO(amt uint64, assetID ids.ID, addr ids.ShortID) *avax.UTXO { + return &avax.UTXO{ + UTXOID: avax.UTXOID{TxID: ids.GenerateTestID()}, + Asset: avax.Asset{ID: assetID}, + Out: NewTransferOutput(amt, addr), + } +} + +// ExportedUTXOs returns the UTXOs produced by e when wrapped in a [tx.Tx] +// with the given txID. +func ExportedUTXOs(txID ids.ID, e *tx.Export) []*avax.UTXO { + utxos := make([]*avax.UTXO, len(e.ExportedOutputs)) + for i, out := range e.ExportedOutputs { + utxos[i] = &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: uint32(i), //#nosec G115 -- Won't overflow + }, + Asset: out.Asset, + Out: out.Out, + } + } + return utxos +} diff --git a/vms/saevm/cchain/vm.go b/vms/saevm/cchain/vm.go new file mode 100644 index 000000000000..5cca2dd7354a --- /dev/null +++ b/vms/saevm/cchain/vm.go @@ -0,0 +1,200 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package cchain implements the C-Chain VM atop [sae.VM]. It composes the +// C-Chain block-building hooks, the cross-chain transaction pool, and the avax +// JSON-RPC service that ingests Export and Import transactions. +package cchain + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/core/txpool/legacypool" + "github.com/ava-labs/libevm/triedb" + + "github.com/ava-labs/avalanchego/database/prefixdb" + "github.com/ava-labs/avalanchego/graft/evm/utils/rpc" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/snow/engine/common" + "github.com/ava-labs/avalanchego/vms/evm/database" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/state" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/txpool" + "github.com/ava-labs/avalanchego/vms/saevm/sae" + "github.com/ava-labs/avalanchego/vms/saevm/saedb" + + avadb "github.com/ava-labs/avalanchego/database" +) + +// VM wraps an [sae.VM] with the cross-chain pieces specific to the C-Chain. +type VM struct { + *sae.VM // created by [VM.Initialize] + + ctx *snow.Context + state *state.State + txpool *txpool.Txpool + + // onClose are executed in reverse order during [VM.Shutdown]. If a resource + // depends on another resource, it MUST be added AFTER the resource it + // depends on. + onClose []func(context.Context) error +} + +var ethDBPrefix = []byte("ethdb") + +// Initialize initializes the VM. +func (v *VM) Initialize( + ctx context.Context, + snowCtx *snow.Context, + avaDB avadb.Database, + genesisBytes []byte, + _ []byte, + configBytes []byte, + _ []*common.Fx, + appSender common.AppSender, +) (retErr error) { + defer func() { + if retErr != nil { + retErr = errors.Join(retErr, v.Shutdown(ctx)) + } + }() + + v.ctx = snowCtx + + // TODO(StephenButtolph): Allow minimal user configuration via configBytes. + _ = configBytes + + // [prefixdb.NewNested] is used because coreth used to be run as a plugin. + // This meant that the database's prefix was not compacted, because the + // provided database was wrapped by the rpcchainvm. + ethDB := rawdb.NewDatabase(database.New(prefixdb.NewNested(ethDBPrefix, avaDB))) + trieDBConfig := triedb.HashDefaults + trieDB := triedb.NewDatabase(ethDB, trieDBConfig) + + // TODO(StephenButtolph): Replace this with Coreth's genesis format. + genesis := new(core.Genesis) + if err := json.Unmarshal(genesisBytes, genesis); err != nil { + return fmt.Errorf("unmarshalling genesis: %w", err) + } + chainConfig, _, err := core.SetupGenesisBlock(ethDB, trieDB, genesis) + if err != nil { + return fmt.Errorf("setting up genesis block: %w", err) + } + + v.state, err = state.New(snowCtx, avaDB) + if err != nil { + return fmt.Errorf("creating cchain state: %w", err) + } + v.onClose = append(v.onClose, func(context.Context) error { + return v.state.Close() + }) + + pendingTxs := txpool.NewPending() + hooks := newHooks( + snowCtx, + v.state, + pendingTxs, + ) + mempoolConfig := legacypool.DefaultConfig + // Treat all transactions equally regardless of submission source — no + // preferential admission or pricing for locally-submitted txs. + mempoolConfig.NoLocals = true + saeConfig := sae.Config{ + MempoolConfig: mempoolConfig, + DBConfig: saedb.Config{ + TrieDBConfig: trieDBConfig, + }, + } + v.VM, err = sae.NewVM(ctx, hooks, saeConfig, snowCtx, chainConfig, ethDB, genesis.ToBlock(), appSender) + if err != nil { + return fmt.Errorf("creating SAE VM: %w", err) + } + v.onClose = append(v.onClose, v.VM.Shutdown) + + const maxTxPoolSize = 1024 + v.txpool, err = txpool.New(snowCtx, chainConfig, pendingTxs, v.VM, maxTxPoolSize) + if err != nil { + return fmt.Errorf("creating txpool: %w", err) + } + v.onClose = append(v.onClose, func(context.Context) error { + v.txpool.Close() + return nil + }) + return nil +} + +const ( + avaxServiceName = "avax" + avaxHTTPExtensionPath = "/" + avaxServiceName +) + +// CreateHandlers returns the HTTP handlers exposed by the underlying SAE VM +// augmented with the avax service at [avaxHTTPExtensionPath]. +func (v *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) { + m, err := v.VM.CreateHandlers(ctx) + if err != nil { + return nil, fmt.Errorf("creating SAE handlers: %w", err) + } + + service, err := newService(v.ctx, v.txpool, v.state) + if err != nil { + return nil, fmt.Errorf("creating avax service: %w", err) + } + handler, err := rpc.NewHandler(avaxServiceName, service) + if err != nil { + return nil, fmt.Errorf("creating avax RPC handler: %w", err) + } + + m[avaxHTTPExtensionPath] = handler + return m, nil +} + +// WaitForEvent waits for a transaction to be in the txpool or for the SAE VM to +// produce an event. +func (v *VM) WaitForEvent(ctx context.Context) (common.Message, error) { + // TODO(StephenButtolph): Do not busy loop with [common.PendingTxs]. The + // txpools are cleared after block execution, so we may still have + // transactions in the txpool while blocks containing those transactions are + // processing. + + // TODO(StephenButtolph): Wait until the minimum block delay has passed. + + ctx, cancel := context.WithCancel(ctx) + type result struct { + msg common.Message + err error + } + results := make(chan result, 2) + go func() { + defer cancel() + msg, err := v.VM.WaitForEvent(ctx) + results <- result{msg, err} + }() + go func() { + defer cancel() + err := v.txpool.AwaitTxs(ctx) + results <- result{common.PendingTxs, err} + }() + + r := <-results + return r.msg, r.err +} + +// Shutdown releases every resource allocated by [VM.Initialize] in reverse +// order. +// +// It is idempotent and safe to call after a partially-failed [VM.Initialize]. +func (v *VM) Shutdown(ctx context.Context) error { + errs := make([]error, len(v.onClose)) + for i, f := range slices.Backward(v.onClose) { + errs[i] = f(ctx) + } + v.onClose = nil + return errors.Join(errs...) +} diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go new file mode 100644 index 000000000000..c02ceaba3c0a --- /dev/null +++ b/vms/saevm/cchain/vm_test.go @@ -0,0 +1,580 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cchain + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/libevm/options" + "github.com/google/go-cmp/cmp" + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "github.com/ava-labs/avalanchego/chains/atomic" + "github.com/ava-labs/avalanchego/database/memdb" + "github.com/ava-labs/avalanchego/database/prefixdb" + "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm" + "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/customtypes" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow" + "github.com/ava-labs/avalanchego/snow/engine/enginetest" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" + "github.com/ava-labs/avalanchego/snow/snowtest" + "github.com/ava-labs/avalanchego/utils/crypto/secp256k1" + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/vms/components/avax" + "github.com/ava-labs/avalanchego/vms/saevm/blocks" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" + "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" + "github.com/ava-labs/avalanchego/vms/saevm/saetest" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + + snowcommon "github.com/ava-labs/avalanchego/snow/engine/common" + saeparams "github.com/ava-labs/avalanchego/vms/saevm/params" +) + +func TestMain(m *testing.M) { + evm.RegisterAllLibEVMExtras() + goleak.VerifyTestMain(m, saetest.GoleakOptions()...) +} + +// SUT is the system under test for the cchain [VM]. It bundles the [VM] +// itself and an HTTP [Client] connected to an in-process [httptest.Server]. +type SUT struct { + *VM + *Client + + snowCtx *snow.Context + memory *atomic.Memory +} + +type ( + sutConfig struct { + genesis core.Genesis + } + sutOption = options.Option[sutConfig] +) + +// newSUT initializes a cchain [VM], transitions it to [snow.NormalOp], and +// mounts its HTTP handlers behind a local [httptest.Server] at the paths +// [NewClient] expects. +func newSUT(tb testing.TB, opts ...sutOption) *SUT { + tb.Helper() + + var ( + vm = &VM{} + ctx = tb.Context() + db = memdb.New() + cfg = options.ApplyTo(&sutConfig{ + genesis: core.Genesis{ + Config: saetest.ChainConfig(), + Timestamp: saeparams.TauSeconds, + Difficulty: big.NewInt(0), // irrelevant but required to marshal + Alloc: types.GenesisAlloc{}, + }, + }, opts...) + ) + + // The VM and shared memory MUST share an underlying database so that + // [atomic.SharedMemory.Apply] writes to the VM DB. + memory := atomic.NewMemory(prefixdb.New([]byte("sharedmemory"), db)) + snowCtx := snowtest.Context(tb, snowtest.CChainID) + snowCtx.SharedMemory = memory.NewSharedMemory(snowtest.CChainID) + snowCtx.Log = saetest.NewTBLogger(tb, logging.Debug) + + chainDB := prefixdb.New([]byte("chain"), db) + + genesisBytes, err := json.Marshal(cfg.genesis) + require.NoErrorf(tb, err, "json.Marshal(%T)", cfg.genesis) + + // The SAE mempool may push gossip transactions when they are issued. + appSender := &enginetest.Sender{ + SendAppGossipF: func(context.Context, snowcommon.SendConfig, []byte) error { + return nil + }, + } + + require.NoErrorf(tb, vm.Initialize( + ctx, + snowCtx, + chainDB, + genesisBytes, + nil, // upgradeBytes + nil, // configBytes + nil, // fxs + appSender, + ), "%T.Initialize()", vm) + tb.Cleanup(func() { + // The context is cancelled before cleanup is called, so we strip the + // cancellation. + ctx := context.WithoutCancel(tb.Context()) + require.NoErrorf(tb, vm.Shutdown(ctx), "%T.Shutdown()", vm) + }) + require.NoErrorf(tb, vm.SetState(ctx, snow.NormalOp), "%T.SetState(%s)", vm, snow.NormalOp) + + handlers, err := vm.CreateHandlers(ctx) + require.NoErrorf(tb, err, "%T.CreateHandlers()", vm) + + mux := http.NewServeMux() + for path, h := range handlers { + mux.Handle(cchainHTTPPrefix+path, h) + } + server := httptest.NewServer(mux) + tb.Cleanup(server.Close) + + return &SUT{ + VM: vm, + Client: NewClient(server.URL), + snowCtx: snowCtx, + memory: memory, + } +} + +// assertUTXOsExist asserts that the shared memory between peerChainID and the +// C-Chain contains each of the expected UTXOs. +func (s *SUT) assertUTXOsExist(tb testing.TB, peerChainID ids.ID, want ...*avax.UTXO) { + tb.Helper() + + keys := make([][]byte, len(want)) + for i, utxo := range want { + inputID := utxo.InputID() + keys[i] = inputID[:] + } + peerMemory := s.memory.NewSharedMemory(peerChainID) + utxoBytes, err := peerMemory.Get(snowtest.CChainID, keys) + require.NoErrorf(tb, err, "%T.Get()", peerMemory) + + got := make([]*avax.UTXO, len(utxoBytes)) + for i, b := range utxoBytes { + got[i] = txtest.MustParseUTXO(tb, b) + } + if diff := cmp.Diff(want, got, txtest.UTXOCmpOpt()); diff != "" { + tb.Errorf("UTXOs in shared memory with %s (-want +got):\n%s", peerChainID, diff) + } +} + +// addUTXOs puts the given UTXOs into shared memory between peerChainID and the +// C-Chain. +func (s *SUT) addUTXOs(tb testing.TB, peerChainID ids.ID, utxos ...*avax.UTXO) { + tb.Helper() + + elems := make([]*atomic.Element, len(utxos)) + for i, utxo := range utxos { + inputID := utxo.InputID() + e := &atomic.Element{ + Key: inputID[:], + Value: txtest.MustMarshalUTXO(tb, utxo), + } + if o, ok := utxo.Out.(avax.Addressable); ok { + e.Traits = o.Addresses() + } + elems[i] = e + } + peerMemory := s.memory.NewSharedMemory(peerChainID) + err := peerMemory.Apply(map[ids.ID]*atomic.Requests{ + snowtest.CChainID: {PutRequests: elems}, + }) + require.NoErrorf(tb, err, "%T.Apply()", peerMemory) +} + +// balance returns the balance of addr at the last-executed state. +func (s *SUT) balance(tb testing.TB, addr common.Address) uint256.Int { + tb.Helper() + + state, err := s.LastExecutedState() + require.NoErrorf(tb, err, "%T.LastExecutedState()", s.VM) + return *state.GetBalance(addr) +} + +// assertBalance asserts addr's balance at the last-executed state. +func (s *SUT) assertBalance(tb testing.TB, addr common.Address, want uint256.Int) { + tb.Helper() + + got := s.balance(tb, addr) + assert.Equalf(tb, want, got, "balance of %s", addr) +} + +// issueAndExecute submits t through [Client.IssueTx] and drives the consensus +// loop to produce, accept, and execute the next block, which is returned. +func (s *SUT) issueAndExecute(tb testing.TB, t *tx.Tx) *blocks.Block { + tb.Helper() + + require.NoErrorf(tb, s.IssueTx(tb.Context(), t), "%T.IssueTx()", s.Client) + return s.runConsensusLoop(tb) +} + +// assertTxAccepted asserts that [Client.GetTx] returns the given tx at the +// given block height. +func (s *SUT) assertTxAccepted(tb testing.TB, want *tx.Tx, wantHeight uint64) { + tb.Helper() + + got, gotHeight, err := s.GetTx(tb.Context(), want.ID()) + require.NoErrorf(tb, err, "%T.GetTx()", s.Client) + if diff := cmp.Diff(want, got, txtest.CmpOpt()); diff != "" { + tb.Errorf("%T.GetTx() (-want +got):\n%s", s.Client, diff) + } + assert.Equalf(tb, wantHeight, gotHeight, "%T.GetTx() block height", s.Client) +} + +// runConsensusLoop builds a block on top of the last-accepted block, drives it +// through verify+accept, and waits until it has been executed. +func (s *SUT) runConsensusLoop(tb testing.TB) *blocks.Block { + tb.Helper() + + blk := s.buildVerifyAccept(tb) + require.NoErrorf(tb, blk.WaitUntilExecuted(tb.Context()), "%T.WaitUntilExecuted()", blk) + return blk +} + +// buildVerifyAccept builds, verifies, and accepts a block on top of the +// last-accepted block. +func (s *SUT) buildVerifyAccept(tb testing.TB) *blocks.Block { + tb.Helper() + + blk := s.buildVerify(tb, s.lastAccepted(tb)) + require.NoErrorf(tb, s.AcceptBlock(tb.Context(), blk), "%T.AcceptBlock()", s.VM) + return blk +} + +// lastAccepted returns the ID of the last-accepted block. +func (s *SUT) lastAccepted(tb testing.TB) ids.ID { + tb.Helper() + + id, err := s.LastAccepted(tb.Context()) + require.NoErrorf(tb, err, "%T.LastAccepted()", s.VM) + return id +} + +// buildVerify builds and verifies a block on top of preferenceID. +func (s *SUT) buildVerify(tb testing.TB, preferenceID ids.ID) *blocks.Block { + tb.Helper() + + ctx := tb.Context() + // TODO(StephenButtolph): When implementing Warp, we will need to provide + // meaningful block contexts. + var blockCtx *block.Context + require.NoErrorf(tb, s.SetPreference(ctx, preferenceID, blockCtx), "%T.SetPreference()", s.VM) + + e, err := s.WaitForEvent(ctx) + require.NoErrorf(tb, err, "%T.WaitForEvent()", s.VM) + assert.Equalf(tb, snowcommon.PendingTxs, e, "%T.WaitForEvent() event", s.VM) + + blk, err := s.BuildBlock(ctx, blockCtx) + require.NoErrorf(tb, err, "%T.BuildBlock()", s.VM) + require.NoErrorf(tb, s.VerifyBlock(ctx, blockCtx, blk), "%T.VerifyBlock()", s.VM) + return blk +} + +// wallet builds and signs cross-chain transactions on behalf of a single key. +type wallet struct { + sk *secp256k1.PrivateKey + snowCtx *snow.Context + client *Client + nonce uint64 +} + +// newWallet returns a [*wallet] backed by sk for the chain described by +// snowCtx. client is queried when building imports to discover spendable +// UTXOs. +func newWallet(sk *secp256k1.PrivateKey, snowCtx *snow.Context, client *Client) *wallet { + return &wallet{ + sk: sk, + snowCtx: snowCtx, + client: client, + } +} + +// newExportTx builds and signs an [tx.Export] sending outputs to +// destinationChain. The wallet contributes a single AVAX input from its eth +// address with Amount = sum(outputs.Amt) + fee, using its next nonce. +func (w *wallet) newExportTx( + tb testing.TB, + destinationChain ids.ID, + outputs []*secp256k1fx.TransferOutput, + fee uint64, +) (*tx.Tx, *tx.Export) { + tb.Helper() + + avaxAssetID := w.snowCtx.AVAXAssetID + var exportedAmount uint64 + transferable := make([]*avax.TransferableOutput, len(outputs)) + for i, out := range outputs { + transferable[i] = &avax.TransferableOutput{ + Asset: avax.Asset{ID: avaxAssetID}, + Out: out, + } + exportedAmount += out.Amt + } + + export := &tx.Export{ + NetworkID: w.snowCtx.NetworkID, + BlockchainID: w.snowCtx.ChainID, + DestinationChain: destinationChain, + Ins: []tx.Input{{ + Address: w.sk.EthAddress(), + Amount: exportedAmount + fee, + AssetID: avaxAssetID, + Nonce: w.nonce, + }}, + ExportedOutputs: transferable, + } + w.nonce++ + + return w.sign(tb, export, 1), export +} + +// newImportTx builds and signs an [tx.Import] consuming all spendable AVAX +// UTXOs that have been exported to this chain from sourceChain and are owned +// by the wallet, crediting the total imported (minus fee) to `to` on the +// C-Chain. +func (w *wallet) newImportTx( + tb testing.TB, + sourceChain ids.ID, + to common.Address, + fee uint64, +) (*tx.Tx, *tx.Import) { + tb.Helper() + + utxos := w.getUTXOs(tb, sourceChain) + + var ( + avaxAssetID = w.snowCtx.AVAXAssetID + importedAVAX uint64 + inputs = make([]*avax.TransferableInput, 0, len(utxos)) + ) + for _, utxo := range utxos { + if utxo.Asset.ID != avaxAssetID { + continue + } + + out, ok := utxo.Out.(*secp256k1fx.TransferOutput) + require.Truef(tb, ok, "unexpected UTXO output type %T", utxo.Out) + + importedAVAX += out.Amt + inputs = append(inputs, &avax.TransferableInput{ + UTXOID: utxo.UTXOID, + Asset: utxo.Asset, + In: &secp256k1fx.TransferInput{ + Amt: out.Amt, + Input: secp256k1fx.Input{ + SigIndices: []uint32{0}, + }, + }, + }) + } + require.Greaterf(tb, importedAVAX, fee, "imported AVAX insufficient to cover fee") + + imp := &tx.Import{ + NetworkID: w.snowCtx.NetworkID, + BlockchainID: w.snowCtx.ChainID, + SourceChain: sourceChain, + ImportedInputs: inputs, + Outs: []tx.Output{{ + Address: to, + Amount: importedAVAX - fee, + AssetID: avaxAssetID, + }}, + } + return w.sign(tb, imp, len(inputs)), imp +} + +// getUTXOs returns every UTXO controlled by the wallet that has been exported +// to this chain from sourceChain. +func (w *wallet) getUTXOs(tb testing.TB, sourceChain ids.ID) []*avax.UTXO { + tb.Helper() + return getUTXOs(tb, w.client, sourceChain, maxGetUTXOsLimit, w.sk.Address()) +} + +// getUTXOs drains [Client.GetUTXOs] for addrs by walking pages of size limit +// until a short page signals the end of the result set. +func getUTXOs( + tb testing.TB, + client *Client, + sourceChain ids.ID, + limit uint32, + addrs ...ids.ShortID, +) []*avax.UTXO { + tb.Helper() + + var ( + startAddr ids.ShortID + startUTXOID ids.ID + utxos []*avax.UTXO + ) + for { + page, endAddr, endUTXOID, err := client.GetUTXOs( + tb.Context(), + addrs, + sourceChain, + limit, + startAddr, + startUTXOID, + ) + require.NoErrorf(tb, err, "%T.GetUTXOs()", client) + utxos = append(utxos, page...) + if uint32(len(page)) < limit { + return utxos + } + startAddr, startUTXOID = endAddr, endUTXOID + } +} + +// sign wraps u in a [tx.Tx] with numCreds copies of a single-sig credential +// over u. +func (w *wallet) sign(tb testing.TB, u tx.Unsigned, numCreds int) *tx.Tx { + tb.Helper() + + sig := txtest.Sign(tb, u, w.sk) + creds := make([]tx.Credential, numCreds) + for i := range creds { + creds[i] = &secp256k1fx.Credential{Sigs: []txtest.Signature{sig}} + } + return &tx.Tx{ + Unsigned: u, + Creds: creds, + } +} + +var x2cRate = big.NewInt(tx.X2CRate) + +// addNAVAX returns balance + nAVAXDelta. nAVAXDelta may be negative. +func addNAVAX(tb testing.TB, balance uint256.Int, nAVAXDelta int64) uint256.Int { + tb.Helper() + + delta := big.NewInt(nAVAXDelta) + delta.Mul(delta, x2cRate) + bigBalance := balance.ToBig() + bigBalance.Add(bigBalance, delta) + + result, overflow := uint256.FromBig(bigBalance) + require.Falsef(tb, overflow, "addNAVAX(%s, %d) overflows uint256", balance, nAVAXDelta) + return *result +} + +// TestExport exercises the cchain VM end-to-end with an Export tx. +func TestExport(t *testing.T) { + sk := txtest.NewKey(t) + sender := sk.EthAddress() + sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + c.genesis.Alloc = saetest.MaxAllocFor(sender) + })) + + const ( + exportedAmount = 50 + txFee = 50 + ) + + w := newWallet(sk, sut.snowCtx, sut.Client) + signedExport, export := w.newExportTx( + t, + sut.snowCtx.XChainID, + []*secp256k1fx.TransferOutput{ + txtest.NewTransferOutput(exportedAmount, sk.Address()), + }, + txFee, + ) + + initialBalance := sut.balance(t, sender) + blk := sut.issueAndExecute(t, signedExport) + sut.assertTxAccepted(t, signedExport, blk.NumberU64()) + const amountBurned = exportedAmount + txFee + sut.assertBalance(t, sender, addNAVAX(t, initialBalance, -amountBurned)) + sut.assertUTXOsExist(t, sut.snowCtx.XChainID, txtest.ExportedUTXOs(signedExport.ID(), export)...) +} + +// TestImport exercises the cchain VM end-to-end with an Import tx. +func TestImport(t *testing.T) { + sut := newSUT(t) + + const utxoAmount = 100 + sk := txtest.NewKey(t) + sut.addUTXOs( + t, + snowtest.XChainID, + txtest.NewUTXO(utxoAmount, sut.snowCtx.AVAXAssetID, sk.Address()), + ) + + w := newWallet(sk, sut.snowCtx, sut.Client) + receiver := txtest.NewKey(t).EthAddress() + const txFee = 50 + signedImport, _ := w.newImportTx(t, sut.snowCtx.XChainID, receiver, txFee) + + blk := sut.issueAndExecute(t, signedImport) + sut.assertTxAccepted(t, signedImport, blk.NumberU64()) + const amountMinted = utxoAmount - txFee + sut.assertBalance(t, receiver, tx.ScaleAVAX(amountMinted)) +} + +// TestBuildBlockOnProcessing verifies that the block builder excludes a mempool +// candidate whose inputs were already consumed by an unsettled ancestor block. +func TestBuildBlockOnProcessing(t *testing.T) { + var ( + skA = txtest.NewKey(t) + skB = txtest.NewKey(t) + ) + sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + c.genesis.Alloc = saetest.MaxAllocFor( + skA.EthAddress(), + skB.EthAddress(), + ) + })) + + newExport := func(w *wallet) *tx.Tx { + signed, _ := w.newExportTx( + t, + sut.snowCtx.XChainID, + []*secp256k1fx.TransferOutput{ + txtest.NewTransferOutput(50, w.sk.Address()), + }, + 50, + ) + return signed + } + + ctx := t.Context() + wA := newWallet(skA, sut.snowCtx, sut.Client) + txA := newExport(wA) + require.NoErrorf(t, sut.IssueTx(ctx, txA), "%T.IssueTx(txA)", sut.Client) + blockA := sut.buildVerify(t, sut.lastAccepted(t)) + if diff := cmp.Diff([]*tx.Tx{txA}, blockTxs(t, blockA), txtest.CmpOpt()); diff != "" { + t.Errorf("%T txs (-want +got):\n%s", blockA, diff) + } + + // blockA is verified but not accepted, so txA stays in the mempool and + // is presented to blockB's builder as a candidate. + wB := newWallet(skB, sut.snowCtx, sut.Client) + txB := newExport(wB) + require.NoErrorf(t, sut.IssueTx(ctx, txB), "%T.IssueTx(txB)", sut.Client) + blockB := sut.buildVerify(t, blockA.ID()) + if diff := cmp.Diff([]*tx.Tx{txB}, blockTxs(t, blockB), txtest.CmpOpt()); diff != "" { + t.Errorf("%T txs (-want +got):\n%s", blockB, diff) + } + + require.NoErrorf(t, sut.AcceptBlock(ctx, blockA), "%T.AcceptBlock(blockA)", sut.VM) + require.NoErrorf(t, sut.AcceptBlock(ctx, blockB), "%T.AcceptBlock(blockB)", sut.VM) + require.NoErrorf(t, blockA.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(blockA)", blockA) + require.NoErrorf(t, blockB.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(blockB)", blockB) + + sut.assertTxAccepted(t, txA, blockA.NumberU64()) + sut.assertTxAccepted(t, txB, blockB.NumberU64()) +} + +// blockTxs returns every cross-chain tx encoded in the block. +func blockTxs(tb testing.TB, blk *blocks.Block) []*tx.Tx { + tb.Helper() + + txs, err := tx.ParseSlice(customtypes.BlockExtData(blk.EthBlock())) + require.NoErrorf(tb, err, "tx.ParseSlice()") + return txs +} diff --git a/vms/saevm/sae/BUILD.bazel b/vms/saevm/sae/BUILD.bazel index f6e2c587e0da..22d973db9f5b 100644 --- a/vms/saevm/sae/BUILD.bazel +++ b/vms/saevm/sae/BUILD.bazel @@ -56,6 +56,7 @@ go_library( "@com_github_ava_labs_libevm//core/types", "@com_github_ava_labs_libevm//ethdb", "@com_github_ava_labs_libevm//event", + "@com_github_ava_labs_libevm//libevm", "@com_github_ava_labs_libevm//params", "@com_github_ava_labs_libevm//rlp", "@com_github_ava_labs_libevm//triedb", diff --git a/vms/saevm/sae/consensus.go b/vms/saevm/sae/consensus.go index 56f23dd6107d..6d8ee6faa06a 100644 --- a/vms/saevm/sae/consensus.go +++ b/vms/saevm/sae/consensus.go @@ -7,8 +7,10 @@ import ( "context" "fmt" + "github.com/ava-labs/libevm/core" "github.com/ava-labs/libevm/core/rawdb" "github.com/ava-labs/libevm/event" + "github.com/ava-labs/libevm/libevm" "go.uber.org/zap" "github.com/ava-labs/avalanchego/ids" @@ -136,3 +138,15 @@ func (vm *VM) RejectBlock(ctx context.Context, b *blocks.Block) error { func (vm *VM) SubscribeAcceptedBlocks(ch chan<- *blocks.Block) event.Subscription { return vm.acceptedBlocks.Subscribe(ch) } + +// SubscribeChainHeadEvent returns a new subscription for each +// [core.ChainHeadEvent] emitted after a block has been executed. +func (vm *VM) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription { + return vm.exec.SubscribeChainHeadEvent(ch) +} + +// LastExecutedState returns a [libevm.StateReader] backed by the post-execution +// state of the last-executed block. +func (vm *VM) LastExecutedState() (libevm.StateReader, error) { + return vm.exec.StateDB(vm.exec.LastExecuted().PostExecutionStateRoot()) +} diff --git a/vms/saevm/sae/rpc.go b/vms/saevm/sae/rpc.go index 8597709d0a26..84689bdfac6d 100644 --- a/vms/saevm/sae/rpc.go +++ b/vms/saevm/sae/rpc.go @@ -5,6 +5,7 @@ package sae import ( "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core" "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/ethdb" "github.com/ava-labs/libevm/event" @@ -55,3 +56,7 @@ func (c chain) NewBlock(eth *types.Block, parent, lastSettled *blocks.Block) (*b func (c chain) SubscribeAcceptedBlocks(ch chan<- *blocks.Block) event.Subscription { return c.acceptedBlocks.Subscribe(ch) } + +func (c chain) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription { + return c.Executor.SubscribeChainHeadEvent(ch) +} diff --git a/vms/saevm/sae/vm_test.go b/vms/saevm/sae/vm_test.go index 69603820a64f..57e6e36d5f30 100644 --- a/vms/saevm/sae/vm_test.go +++ b/vms/saevm/sae/vm_test.go @@ -65,20 +65,7 @@ import ( func TestMain(m *testing.M) { log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelError, true))) - goleak.VerifyTestMain( - m, - goleak.IgnoreCurrent(), - // ChainIndexer.Close() may check if the event loop is active before it is marked as active. - goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core.(*ChainIndexer).eventLoop"), - // diskLayer.Release() doesn't properly stop generation. - goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core/state/snapshot.(*diskLayer).generate"), - // TxPool.Close() doesn't wait for its loop() method to signal termination. - goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core/txpool.(*TxPool).loop.func2"), - // Not all filters subscriptions can be closed after the TxPool is closed. - goleak.IgnoreTopFunction("github.com/ava-labs/libevm/eth/filters.(*FilterAPI).Logs.func1.deferwrap1.(*Subscription).Unsubscribe.1"), - goleak.IgnoreTopFunction("github.com/ava-labs/libevm/eth/filters.(*FilterAPI).NewHeads.func1.deferwrap1.(*Subscription).Unsubscribe.1"), - goleak.IgnoreTopFunction("github.com/ava-labs/libevm/eth/filters.(*FilterAPI).NewPendingTransactions.func1.deferwrap1.(*Subscription).Unsubscribe.1"), - ) + goleak.VerifyTestMain(m, saetest.GoleakOptions()...) } // SUT is the system under test. Testing SHOULD be performed via the embedded diff --git a/vms/saevm/saetest/BUILD.bazel b/vms/saevm/saetest/BUILD.bazel index 6186a82d7560..04f7460ac8c6 100644 --- a/vms/saevm/saetest/BUILD.bazel +++ b/vms/saevm/saetest/BUILD.bazel @@ -5,6 +5,7 @@ go_library( name = "saetest", testonly = True, srcs = [ + "goleak.go", "heightdb.go", "logging.go", "saetest.go", @@ -29,6 +30,7 @@ go_library( "@com_github_ava_labs_libevm//trie", "@com_github_holiman_uint256//:uint256", "@com_github_stretchr_testify//require", + "@org_uber_go_goleak//:goleak", "@org_uber_go_zap//:zap", "@org_uber_go_zap//zapcore", ], diff --git a/vms/saevm/saetest/goleak.go b/vms/saevm/saetest/goleak.go new file mode 100644 index 000000000000..6bdf15314fb4 --- /dev/null +++ b/vms/saevm/saetest/goleak.go @@ -0,0 +1,27 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package saetest + +import "go.uber.org/goleak" + +// GoleakOptions ignores known leaking goroutines in libevm. It also includes +// [goleak.IgnoreCurrent]. +func GoleakOptions() []goleak.Option { + return []goleak.Option{ + goleak.IgnoreCurrent(), + // ChainIndexer.Close() may check if the event loop is active before it + // is marked as active. + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core.(*ChainIndexer).eventLoop"), + // diskLayer.Release() doesn't properly stop generation. + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core/state/snapshot.(*diskLayer).generate"), + // TxPool.Close() doesn't wait for its loop() method to signal + // termination. + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core/txpool.(*TxPool).loop.func2"), + // Not all filters subscriptions can be closed after the TxPool is + // closed. + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/eth/filters.(*FilterAPI).Logs.func1.deferwrap1.(*Subscription).Unsubscribe.1"), + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/eth/filters.(*FilterAPI).NewHeads.func1.deferwrap1.(*Subscription).Unsubscribe.1"), + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/eth/filters.(*FilterAPI).NewPendingTransactions.func1.deferwrap1.(*Subscription).Unsubscribe.1"), + } +} From 9c580fd99a6bdb362eb3b49cc09684cb63426d4f Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 13:55:36 -0400 Subject: [PATCH 02/27] lint --- vms/saevm/cchain/hooks_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 7aecdd31218a..62e36b673337 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -49,7 +49,7 @@ func TestAncestorInputIDs(t *testing.T) { export := func() *tx.Tx { const ( exportedAmount = 50 - txFee = 50 + txFee = 0 ) signed, _ := w.newExportTx( t, From 0e19552a4d41120313ed3d2aee76d3f8c77e0156 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 13:59:29 -0400 Subject: [PATCH 03/27] nit --- vms/saevm/cchain/hooks_test.go | 4 ++-- vms/saevm/cchain/vm_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 62e36b673337..67e15235571c 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -51,7 +51,7 @@ func TestAncestorInputIDs(t *testing.T) { exportedAmount = 50 txFee = 0 ) - signed, _ := w.newExportTx( + signedExport, _ := w.newExportTx( t, snowtest.XChainID, []*secp256k1fx.TransferOutput{ @@ -59,7 +59,7 @@ func TestAncestorInputIDs(t *testing.T) { }, txFee, ) - return signed + return signedExport } var ( diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index c02ceaba3c0a..4e745f7515fb 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -531,7 +531,7 @@ func TestBuildBlockOnProcessing(t *testing.T) { })) newExport := func(w *wallet) *tx.Tx { - signed, _ := w.newExportTx( + signedExport, _ := w.newExportTx( t, sut.snowCtx.XChainID, []*secp256k1fx.TransferOutput{ @@ -539,7 +539,7 @@ func TestBuildBlockOnProcessing(t *testing.T) { }, 50, ) - return signed + return signedExport } ctx := t.Context() From eda656cd53041be1f9db7c1513ff148e99678bc9 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 14:05:00 -0400 Subject: [PATCH 04/27] nit --- vms/saevm/cchain/api_test.go | 7 ++----- vms/saevm/cchain/hooks_test.go | 7 ++----- vms/saevm/cchain/vm_test.go | 21 ++++++++++----------- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/vms/saevm/cchain/api_test.go b/vms/saevm/cchain/api_test.go index 238a1fff6ec4..6a5a85744fbb 100644 --- a/vms/saevm/cchain/api_test.go +++ b/vms/saevm/cchain/api_test.go @@ -13,7 +13,6 @@ import ( "github.com/ava-labs/avalanchego/snow/snowtest" "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" - "github.com/ava-labs/avalanchego/vms/secp256k1fx" ) // TestIssueTxRejectsInvalidTransaction asserts that [Client.IssueTx] surfaces @@ -24,16 +23,14 @@ func TestIssueTxRejectsInvalidTransaction(t *testing.T) { sk := txtest.NewKey(t) // sk is NOT funded. w := newWallet(sk, sut.snowCtx, sut.Client) const ( - exportedAmount = 50 txFee = 50 + exportedAmount = 50 ) tx, _ := w.newExportTx( t, sut.snowCtx.XChainID, - []*secp256k1fx.TransferOutput{ - txtest.NewTransferOutput(exportedAmount, sk.Address()), - }, txFee, + txtest.NewTransferOutput(exportedAmount, sk.Address()), ) err := sut.IssueTx(t.Context(), tx) diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 67e15235571c..5319d8befebd 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -19,7 +19,6 @@ import ( "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" "github.com/ava-labs/avalanchego/vms/saevm/saetest" - "github.com/ava-labs/avalanchego/vms/secp256k1fx" ) // newBlock returns a minimal [*types.Block] whose ExtData encodes txs and @@ -48,16 +47,14 @@ func TestAncestorInputIDs(t *testing.T) { w := newWallet(txtest.NewKey(t), snowtest.Context(t, snowtest.CChainID), nil) export := func() *tx.Tx { const ( - exportedAmount = 50 txFee = 0 + exportedAmount = 50 ) signedExport, _ := w.newExportTx( t, snowtest.XChainID, - []*secp256k1fx.TransferOutput{ - txtest.NewTransferOutput(exportedAmount, w.sk.Address()), - }, txFee, + txtest.NewTransferOutput(exportedAmount, w.sk.Address()), ) return signedExport } diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 4e745f7515fb..71144a589904 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -301,8 +301,8 @@ func newWallet(sk *secp256k1.PrivateKey, snowCtx *snow.Context, client *Client) func (w *wallet) newExportTx( tb testing.TB, destinationChain ids.ID, - outputs []*secp256k1fx.TransferOutput, fee uint64, + outputs ...*secp256k1fx.TransferOutput, ) (*tx.Tx, *tx.Export) { tb.Helper() @@ -470,19 +470,16 @@ func TestExport(t *testing.T) { c.genesis.Alloc = saetest.MaxAllocFor(sender) })) + w := newWallet(sk, sut.snowCtx, sut.Client) const ( - exportedAmount = 50 txFee = 50 + exportedAmount = 50 ) - - w := newWallet(sk, sut.snowCtx, sut.Client) signedExport, export := w.newExportTx( t, sut.snowCtx.XChainID, - []*secp256k1fx.TransferOutput{ - txtest.NewTransferOutput(exportedAmount, sk.Address()), - }, txFee, + txtest.NewTransferOutput(exportedAmount, sk.Address()), ) initialBalance := sut.balance(t, sender) @@ -531,13 +528,15 @@ func TestBuildBlockOnProcessing(t *testing.T) { })) newExport := func(w *wallet) *tx.Tx { + const ( + txFee = 50 + exportedAmount = 50 + ) signedExport, _ := w.newExportTx( t, sut.snowCtx.XChainID, - []*secp256k1fx.TransferOutput{ - txtest.NewTransferOutput(50, w.sk.Address()), - }, - 50, + txFee, + txtest.NewTransferOutput(exportedAmount, w.sk.Address()), ) return signedExport } From 791223d491b99e80ba232456f1b66f0b8b3f010d Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 14:19:22 -0400 Subject: [PATCH 05/27] nit --- vms/saevm/cchain/vm_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 71144a589904..d00120b5a9a7 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -423,7 +423,7 @@ func getUTXOs( ) require.NoErrorf(tb, err, "%T.GetUTXOs()", client) utxos = append(utxos, page...) - if uint32(len(page)) < limit { + if uint64(len(page)) < uint64(limit) { return utxos } startAddr, startUTXOID = endAddr, endUTXOID From a5dcf135fa862ecb15ede98a19276c673e5620cd Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 14:21:30 -0400 Subject: [PATCH 06/27] nit --- vms/saevm/cchain/api_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vms/saevm/cchain/api_test.go b/vms/saevm/cchain/api_test.go index 6a5a85744fbb..08795e1c5aa7 100644 --- a/vms/saevm/cchain/api_test.go +++ b/vms/saevm/cchain/api_test.go @@ -51,11 +51,11 @@ func TestGetTxNotFound(t *testing.T) { func TestGetUTXOsPagination(t *testing.T) { sut := newSUT(t) - const numUTXOs = 5 + const numUTXOs uint64 = 5 want := make([]*avax.UTXO, numUTXOs) addr := txtest.NewKey(t).Address() - for i := range want { - want[i] = txtest.NewUTXO(uint64(i+1), sut.snowCtx.AVAXAssetID, addr) + for i := range numUTXOs { + want[i] = txtest.NewUTXO(i+1, sut.snowCtx.AVAXAssetID, addr) } sut.addUTXOs(t, snowtest.XChainID, want...) From b77e6a76c22755f21c0fa26d390491080473309f Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 15:43:20 -0400 Subject: [PATCH 07/27] simplify --- vms/saevm/cchain/hooks.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go index 6da3a5d054a2..dd4e99a3907b 100644 --- a/vms/saevm/cchain/hooks.go +++ b/vms/saevm/cchain/hooks.go @@ -70,9 +70,7 @@ func newHooks( builder{ ctx, time.Now, - func() iter.Seq[*hookTx] { - return poolTxs - }, + poolTxs, }, state, } @@ -94,15 +92,12 @@ func (h *hooks) BlockRebuilderFrom(b *types.Block) (hook.BlockBuilder[*hookTx], } now := h.BlockTime(b.Header()) - potentialTxs := slices.Values(txs) return &builder{ h.ctx, func() time.Time { return now }, - func() iter.Seq[*hookTx] { - return potentialTxs - }, + slices.Values(txs), }, nil } @@ -189,7 +184,7 @@ var _ hook.BlockBuilder[*hookTx] = (*builder)(nil) type builder struct { ctx *snow.Context now func() time.Time - potentialTxs func() iter.Seq[*hookTx] + potentialTxs iter.Seq[*hookTx] } func (b *builder) BuildHeader(parent *types.Header) (*types.Header, error) { @@ -230,7 +225,6 @@ func (b *builder) PotentialEndOfBlockOps( settledHash common.Hash, source saetypes.BlockSource, ) iter.Seq[*hookTx] { - seq := b.potentialTxs() return func(yield func(*hookTx) bool) { // Transactions are verified against the last executed state. So, we // must also verify that they don't conflict with any transactions in @@ -243,7 +237,7 @@ func (b *builder) PotentialEndOfBlockOps( return } - for t := range seq { + for t := range b.potentialTxs { if inputs.Overlaps(t.inputs) { b.ctx.Log.Debug("tx consumes previously consumed inputs", zap.Stringer("txID", t.id), From ed51af87587464849e87dfee47cb20a20280374c Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 16:08:44 -0400 Subject: [PATCH 08/27] nit: do not reply nil --- vms/saevm/cchain/api.go | 1 + 1 file changed, 1 insertion(+) diff --git a/vms/saevm/cchain/api.go b/vms/saevm/cchain/api.go index ab722eb02ee8..816a992cebdf 100644 --- a/vms/saevm/cchain/api.go +++ b/vms/saevm/cchain/api.go @@ -122,6 +122,7 @@ func (s *service) GetUTXOs(_ *http.Request, a *api.GetUTXOsArgs, r *api.GetUTXOs } if startAddr == termAddr && startUTXO == termUTXOID { // Client provided the terminal index, so there are no more results. + r.UTXOs = []string{} r.EndIndex.Address = s.zeroAddress r.EndIndex.UTXO = ids.Empty.String() return nil From c142ea8d3e67a42ffa3ba00ee4b62c45da53d773 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 16:33:15 -0400 Subject: [PATCH 09/27] Test nonce changes --- vms/saevm/cchain/vm_test.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index d00120b5a9a7..444899d6896a 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -197,12 +197,18 @@ func (s *SUT) balance(tb testing.TB, addr common.Address) uint256.Int { return *state.GetBalance(addr) } -// assertBalance asserts addr's balance at the last-executed state. -func (s *SUT) assertBalance(tb testing.TB, addr common.Address, want uint256.Int) { +// assertAccount asserts addr's nonce and balance at the last-executed state. +func (s *SUT) assertAccount(tb testing.TB, addr common.Address, wantNonce uint64, wantBalance uint256.Int) { tb.Helper() - got := s.balance(tb, addr) - assert.Equalf(tb, want, got, "balance of %s", addr) + state, err := s.LastExecutedState() + require.NoErrorf(tb, err, "%T.LastExecutedState()", s.VM) + + gotNonce := state.GetNonce(addr) + assert.Equalf(tb, wantNonce, gotNonce, "nonce of %s", addr) + + gotBalance := *state.GetBalance(addr) + assert.Equalf(tb, wantBalance, gotBalance, "balance of %s", addr) } // issueAndExecute submits t through [Client.IssueTx] and drives the consensus @@ -486,7 +492,7 @@ func TestExport(t *testing.T) { blk := sut.issueAndExecute(t, signedExport) sut.assertTxAccepted(t, signedExport, blk.NumberU64()) const amountBurned = exportedAmount + txFee - sut.assertBalance(t, sender, addNAVAX(t, initialBalance, -amountBurned)) + sut.assertAccount(t, sender, 1, addNAVAX(t, initialBalance, -amountBurned)) sut.assertUTXOsExist(t, sut.snowCtx.XChainID, txtest.ExportedUTXOs(signedExport.ID(), export)...) } @@ -510,7 +516,7 @@ func TestImport(t *testing.T) { blk := sut.issueAndExecute(t, signedImport) sut.assertTxAccepted(t, signedImport, blk.NumberU64()) const amountMinted = utxoAmount - txFee - sut.assertBalance(t, receiver, tx.ScaleAVAX(amountMinted)) + sut.assertAccount(t, receiver, 0, tx.ScaleAVAX(amountMinted)) } // TestBuildBlockOnProcessing verifies that the block builder excludes a mempool From aed98895a3b9b04d2d2ee3fe25877e5d77a568fb Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 19 May 2026 16:57:06 -0400 Subject: [PATCH 10/27] cchain > sae --- vms/saevm/cchain/hooks.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go index dd4e99a3907b..e53d9d48d29e 100644 --- a/vms/saevm/cchain/hooks.go +++ b/vms/saevm/cchain/hooks.go @@ -34,7 +34,7 @@ import ( "github.com/ava-labs/avalanchego/vms/saevm/hook" "github.com/ava-labs/avalanchego/x/blockdb" - saestate "github.com/ava-labs/avalanchego/vms/saevm/cchain/state" + cchainstate "github.com/ava-labs/avalanchego/vms/saevm/cchain/state" saetypes "github.com/ava-labs/avalanchego/vms/saevm/types" ethparams "github.com/ava-labs/libevm/params" ) @@ -43,12 +43,12 @@ var _ hook.PointsG[*hookTx] = (*hooks)(nil) type hooks struct { builder - state *saestate.State + state *cchainstate.State } func newHooks( ctx *snow.Context, - state *saestate.State, + state *cchainstate.State, pool *txpool.Pending, ) *hooks { poolTxs := func(yield func(*hookTx) bool) { From 7240b8ea15d1ccd26618199f65b3ce664864381b Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 20 May 2026 16:14:39 -0400 Subject: [PATCH 11/27] suppress AfterExecutingBlock in API calls --- vms/saevm/cchain/vm_test.go | 71 +++++++++++++++++++++++++++++++++++ vms/saevm/sae/rpc/stateful.go | 11 ++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 444899d6896a..c15d2c446fd7 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -22,6 +22,7 @@ import ( "go.uber.org/goleak" "github.com/ava-labs/avalanchego/chains/atomic" + "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/database/memdb" "github.com/ava-labs/avalanchego/database/prefixdb" "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm" @@ -42,6 +43,7 @@ import ( snowcommon "github.com/ava-labs/avalanchego/snow/engine/common" saeparams "github.com/ava-labs/avalanchego/vms/saevm/params" + ethparams "github.com/ava-labs/libevm/params" ) func TestMain(m *testing.M) { @@ -164,6 +166,22 @@ func (s *SUT) assertUTXOsExist(tb testing.TB, peerChainID ids.ID, want ...*avax. } } +// assertUTXOsMissing asserts that the shared memory between peerChainID and the +// C-Chain does not contain any of the expected UTXOs. +func (s *SUT) assertUTXOsMissing(tb testing.TB, peerChainID ids.ID, unwanted ...*avax.UTXO) { + tb.Helper() + + peerMemory := s.memory.NewSharedMemory(peerChainID) + for i, utxo := range unwanted { + inputID := utxo.InputID() + key := inputID[:] + + keys := [][]byte{key} + _, err := peerMemory.Get(snowtest.CChainID, keys) + assert.ErrorIsf(tb, err, database.ErrNotFound, "%T.Get(utxo %d)", peerMemory, i) + } +} + // addUTXOs puts the given UTXOs into shared memory between peerChainID and the // C-Chain. func (s *SUT) addUTXOs(tb testing.TB, peerChainID ids.ID, utxos ...*avax.UTXO) { @@ -583,3 +601,56 @@ func blockTxs(tb testing.TB, blk *blocks.Block) []*tx.Tx { require.NoErrorf(tb, err, "tx.ParseSlice()") return txs } + +// TestDebugTraceDoesNotApplyAtomicState asserts that executing a debug trace +// does not apply atomic state changes before the block is accepted. +func TestDebugTraceDoesNotApplyAtomicState(t *testing.T) { + ethWallet := saetest.NewUNSAFEWallet(t, 1, types.LatestSigner(saetest.ChainConfig())) + ethSender := ethWallet.Addresses()[0] + exportKey := txtest.NewKey(t) + sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + c.genesis.Alloc = saetest.MaxAllocFor( + ethSender, + exportKey.EthAddress(), + ) + })) + + // Tracing will error if there isn't at least one ethereum transaction in + // the block. + tracedTx := ethWallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + To: ðSender, + Gas: ethparams.TxGas, + GasPrice: big.NewInt(1), + }) + rpc := sut.GethRPCBackends() + require.NoErrorf(t, rpc.SendTx(t.Context(), tracedTx), "%T.SendTx(%#x)", rpc, tracedTx.Hash()) + + // Export gives us observable external state. + w := newWallet(exportKey, sut.snowCtx, sut.Client) + const ( + txFee = 50 + exportedAmount = 50 + ) + signedExport, export := w.newExportTx( + t, + sut.snowCtx.XChainID, + txFee, + txtest.NewTransferOutput(exportedAmount, exportKey.Address()), + ) + require.NoErrorf(t, sut.IssueTx(t.Context(), signedExport), "%T.IssueTx()", sut.Client) + + blk := sut.buildVerify(t, sut.lastAccepted(t)) + require.Equalf(t, types.Transactions{tracedTx}, blk.Transactions(), "%T.Transactions()", blk) + if diff := cmp.Diff([]*tx.Tx{signedExport}, blockTxs(t, blk), txtest.CmpOpt()); diff != "" { + t.Errorf("%T txs (-want +got):\n%s", blk, diff) + } + + _, _, _, release, err := rpc.StateAtTransaction(t.Context(), blk.EthBlock(), 0, 0) + require.NoErrorf(t, err, "%T.StateAtTransaction(...)", rpc) + defer release() + + // We haven't accepted the block yet, so it should be impossible for the + // execution results to have been applied. + exportedUTXOs := txtest.ExportedUTXOs(signedExport.ID(), export) + sut.assertUTXOsMissing(t, sut.snowCtx.XChainID, exportedUTXOs...) +} diff --git a/vms/saevm/sae/rpc/stateful.go b/vms/saevm/sae/rpc/stateful.go index f3bf75b455bc..ab46c2ffffea 100644 --- a/vms/saevm/sae/rpc/stateful.go +++ b/vms/saevm/sae/rpc/stateful.go @@ -27,8 +27,8 @@ import ( var noopRelease tracers.StateReleaseFunc = func() {} // noEndOfBlockOps wraps [hook.Points] to suppress -// [hook.Points.EndOfBlockOps], used by the tracer to skip end-of-block -// operations during partial replay. +// [hook.Points.EndOfBlockOps] and [hook.Points.AfterExecutingBlock], used by +// the tracer to skip end-of-block operations during partial replay. type noEndOfBlockOps struct { hook.Points } @@ -36,6 +36,11 @@ type noEndOfBlockOps struct { // EndOfBlockOps always returns nil. func (noEndOfBlockOps) EndOfBlockOps(*types.Block) ([]hook.Op, error) { return nil, nil } +// AfterExecutingBlock always returns nil. +func (noEndOfBlockOps) AfterExecutingBlock(*state.StateDB, *types.Block, types.Receipts) error { + return nil +} + func (b *backend) RPCEVMTimeout() time.Duration { return b.config.EVMTimeout } @@ -195,7 +200,7 @@ func (b *backend) StateAtTransaction(ctx context.Context, ethB *types.Block, txI block, b, txIndex, - noEndOfBlockOps{Points: b.Hooks()}, + noEndOfBlockOps{b.Hooks()}, b.ChainConfig(), b.ChainContext(), &saexec.NullReceiptStore{}, From a93c6a4ea7e6100ab2e120dc1be906f38d56ec25 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 20 May 2026 16:26:54 -0400 Subject: [PATCH 12/27] nit --- vms/saevm/cchain/BUILD.bazel | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vms/saevm/cchain/BUILD.bazel b/vms/saevm/cchain/BUILD.bazel index 61118a8900a5..173c0ee056ad 100644 --- a/vms/saevm/cchain/BUILD.bazel +++ b/vms/saevm/cchain/BUILD.bazel @@ -77,6 +77,7 @@ go_test( embed = [":cchain"], deps = [ "//chains/atomic", + "//database", "//database/memdb", "//database/prefixdb", "//graft/coreth/plugin/evm", @@ -101,6 +102,7 @@ go_test( "@com_github_ava_labs_libevm//core", "@com_github_ava_labs_libevm//core/types", "@com_github_ava_labs_libevm//libevm/options", + "@com_github_ava_labs_libevm//params", "@com_github_google_go_cmp//cmp", "@com_github_holiman_uint256//:uint256", "@com_github_stretchr_testify//assert", From 48c5ead28c2d97c8cc3eac4999fe107905cf0d02 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 21 May 2026 16:32:09 -0400 Subject: [PATCH 13/27] doc --- vms/saevm/cchain/hooks.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go index e53d9d48d29e..c7fb66ee7c46 100644 --- a/vms/saevm/cchain/hooks.go +++ b/vms/saevm/cchain/hooks.go @@ -219,6 +219,14 @@ func (b *builder) BuildHeader(parent *types.Header) (*types.Header, error) { ), nil } +// PotentialEndOfBlockOps returns the cross-chain transactions that should be +// considered for inclusion in the block being built. +// +// This method MUST only return transactions that are valid to be accepted with +// respect to shared memory. +// +// SAE will perform additional checks on the transactions to ensure they are +// valid with respect to the worst-case state. func (b *builder) PotentialEndOfBlockOps( ctx context.Context, header *types.Header, @@ -226,9 +234,11 @@ func (b *builder) PotentialEndOfBlockOps( source saetypes.BlockSource, ) iter.Seq[*hookTx] { return func(yield func(*hookTx) bool) { - // Transactions are verified against the last executed state. So, we - // must also verify that they don't conflict with any transactions in - // blocks between the block we are building and the last executed block. + // Transactions are verified against the last executed state. We must + // also verify that they don't conflict with any transactions in blocks + // between the block we are building and the last executed block. Since + // we know the settled block has been executed, we use that as our + // reference point. inputs, err := ancestorInputIDs(header, settledHash, source) if err != nil { b.ctx.Log.Error("failed to get ancestor input IDs", @@ -244,6 +254,10 @@ func (b *builder) PotentialEndOfBlockOps( ) continue } + + // Transactions in the txpool have already been sanity checked and + // had their credentials verified, but we also process transactions + // from blocks provided by peers here. if err := t.tx.SanityCheck(b.ctx); err != nil { b.ctx.Log.Debug("tx failed sanity check", zap.Stringer("txID", t.id), @@ -251,6 +265,10 @@ func (b *builder) PotentialEndOfBlockOps( ) continue } + + // Even for transactions from the txpool, we need to ensure that + // Import txs are consuming UTXOs that still exist so that our + // in-memory UTXO conflict checks are sufficient. if err := t.tx.VerifyCredentials(b.ctx.SharedMemory); err != nil { b.ctx.Log.Debug("tx failed credential verification", zap.Stringer("txID", t.id), From dac2669a7cfbbbde2a06d9f89807bfd55c703e34 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 21 May 2026 16:50:14 -0400 Subject: [PATCH 14/27] nits --- vms/saevm/cchain/hooks_test.go | 2 +- vms/saevm/cchain/vm_test.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 5319d8befebd..9376f52595c0 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -115,7 +115,7 @@ func TestAncestorInputIDs(t *testing.T) { } got, err := ancestorInputIDs(tt.header, tt.settled, source) - require.ErrorIs(t, err, tt.wantErr, "ancestorInputIDs()") + assert.ErrorIs(t, err, tt.wantErr, "ancestorInputIDs()") assert.Equal(t, tt.want, got, "ancestorInputIDs()") }) } diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index c15d2c446fd7..e45db2fcb6a6 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -586,7 +586,6 @@ func TestBuildBlockOnProcessing(t *testing.T) { require.NoErrorf(t, sut.AcceptBlock(ctx, blockA), "%T.AcceptBlock(blockA)", sut.VM) require.NoErrorf(t, sut.AcceptBlock(ctx, blockB), "%T.AcceptBlock(blockB)", sut.VM) - require.NoErrorf(t, blockA.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(blockA)", blockA) require.NoErrorf(t, blockB.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(blockB)", blockB) sut.assertTxAccepted(t, txA, blockA.NumberU64()) From 115e52f696aa175df9c6a389a725099e094abd6e Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 21 May 2026 19:38:12 -0400 Subject: [PATCH 15/27] lint --- vms/saevm/cchain/hooks_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 9376f52595c0..5319d8befebd 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -115,7 +115,7 @@ func TestAncestorInputIDs(t *testing.T) { } got, err := ancestorInputIDs(tt.header, tt.settled, source) - assert.ErrorIs(t, err, tt.wantErr, "ancestorInputIDs()") + require.ErrorIs(t, err, tt.wantErr, "ancestorInputIDs()") assert.Equal(t, tt.want, got, "ancestorInputIDs()") }) } From 155496aa2f7f8dec3c0e1158dc3838421ae63c6a Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 10:00:20 -0400 Subject: [PATCH 16/27] nit --- vms/saevm/cchain/tx/tx_test.go | 2 +- vms/saevm/cchain/tx/txtest/wallet.go | 8 ++++---- vms/saevm/cchain/vm_test.go | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/vms/saevm/cchain/tx/tx_test.go b/vms/saevm/cchain/tx/tx_test.go index c1fa3b2798c3..8310ecadec28 100644 --- a/vms/saevm/cchain/tx/tx_test.go +++ b/vms/saevm/cchain/tx/tx_test.go @@ -1774,7 +1774,7 @@ func TestVerifyCredentials(t *testing.T) { validInputID = validUTXOID.InputID() validUTXOs = []*chainsatomic.Element{{ Key: validInputID[:], - Value: txtest.MustMarshalUTXO(t, validUTXO), + Value: txtest.MarshalUTXO(t, validUTXO), }} validImportTx = func() *Tx { diff --git a/vms/saevm/cchain/tx/txtest/wallet.go b/vms/saevm/cchain/tx/txtest/wallet.go index 236c72fd50ce..efa14e88dade 100644 --- a/vms/saevm/cchain/tx/txtest/wallet.go +++ b/vms/saevm/cchain/tx/txtest/wallet.go @@ -41,8 +41,8 @@ func Sign(tb testing.TB, u tx.Unsigned, s keychain.Signer) Signature { return Signature(sig) } -// MustMarshalUTXO returns the canonical binary format of utxo. -func MustMarshalUTXO(tb testing.TB, utxo *avax.UTXO) []byte { +// MarshalUTXO returns the canonical binary format of utxo. +func MarshalUTXO(tb testing.TB, utxo *avax.UTXO) []byte { tb.Helper() b, err := tx.MarshalUTXO(utxo) @@ -50,8 +50,8 @@ func MustMarshalUTXO(tb testing.TB, utxo *avax.UTXO) []byte { return b } -// MustParseUTXO deserializes an [avax.UTXO] from its canonical binary format. -func MustParseUTXO(tb testing.TB, b []byte) *avax.UTXO { +// ParseUTXO deserializes an [avax.UTXO] from its canonical binary format. +func ParseUTXO(tb testing.TB, b []byte) *avax.UTXO { tb.Helper() utxo, err := tx.ParseUTXO(b) diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index e45db2fcb6a6..341198a8b637 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -159,7 +159,7 @@ func (s *SUT) assertUTXOsExist(tb testing.TB, peerChainID ids.ID, want ...*avax. got := make([]*avax.UTXO, len(utxoBytes)) for i, b := range utxoBytes { - got[i] = txtest.MustParseUTXO(tb, b) + got[i] = txtest.ParseUTXO(tb, b) } if diff := cmp.Diff(want, got, txtest.UTXOCmpOpt()); diff != "" { tb.Errorf("UTXOs in shared memory with %s (-want +got):\n%s", peerChainID, diff) @@ -192,7 +192,7 @@ func (s *SUT) addUTXOs(tb testing.TB, peerChainID ids.ID, utxos ...*avax.UTXO) { inputID := utxo.InputID() e := &atomic.Element{ Key: inputID[:], - Value: txtest.MustMarshalUTXO(tb, utxo), + Value: txtest.MarshalUTXO(tb, utxo), } if o, ok := utxo.Out.(avax.Addressable); ok { e.Traits = o.Addresses() From 67c51f24ab3a5c7f0eae6d93d5897e73a544db10 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 10:24:08 -0400 Subject: [PATCH 17/27] nit --- utils/set/set.go | 9 ++++++++ utils/set/set_test.go | 42 ++++++++++++++++++++++++++++++++++ vms/saevm/cchain/hooks_test.go | 10 +------- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/utils/set/set.go b/utils/set/set.go index 450c6f416b51..f16a9b56fc14 100644 --- a/utils/set/set.go +++ b/utils/set/set.go @@ -31,6 +31,15 @@ func Of[T comparable](elts ...T) Set[T] { return s } +// UnionOf returns a new Set that is the union of the provided sets. +func UnionOf[T comparable](sets ...Set[T]) Set[T] { + var s Set[T] + for _, set := range sets { + s.Union(set) + } + return s +} + // Return a new set with initial capacity [size]. // More or less than [size] elements can be added to this set. // Using NewSet() rather than Set[T]{} is just an optimization that can diff --git a/utils/set/set_test.go b/utils/set/set_test.go index 6ffc213ea805..d0f05ba4b238 100644 --- a/utils/set/set_test.go +++ b/utils/set/set_test.go @@ -87,6 +87,48 @@ func TestOf(t *testing.T) { } } +func TestUnionOf(t *testing.T) { + tests := []struct { + name string + elements []Set[int] + expected Set[int] + }{ + { + name: "nil", + elements: nil, + expected: nil, + }, + { + name: "empty", + elements: []Set[int]{ + {}, + }, + expected: Set[int]{}, + }, + { + name: "single_set", + elements: []Set[int]{ + Of(1, 2, 3), + }, + expected: Of(1, 2, 3), + }, + { + name: "multiple_sets", + elements: []Set[int]{ + Of(1, 2), + Of(2, 3), + }, + expected: Of(1, 2, 3), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := UnionOf(tt.elements...) + require.Equal(t, tt.expected, s) + }) + } +} + func TestSetClear(t *testing.T) { require := require.New(t) diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 5319d8befebd..f155a98fca21 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -94,7 +94,7 @@ func TestAncestorInputIDs(t *testing.T) { name: "multiple ancestors", header: block4.Header(), settled: settled, - want: union(tx1.InputIDs(), tx2.InputIDs(), tx3.InputIDs()), + want: set.UnionOf(tx1.InputIDs(), tx2.InputIDs(), tx3.InputIDs()), }, { name: "missing block", @@ -120,11 +120,3 @@ func TestAncestorInputIDs(t *testing.T) { }) } } - -func union[T comparable](sets ...set.Set[T]) set.Set[T] { - var out set.Set[T] - for _, s := range sets { - out.Union(s) - } - return out -} From a83f8380f2f509392b1af8465b14284c43af24f5 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 10:31:44 -0400 Subject: [PATCH 18/27] nit --- vms/saevm/cchain/tx/export.go | 8 ++++---- vms/saevm/cchain/tx/import.go | 8 ++++---- vms/saevm/cchain/tx/tx.go | 21 +++++++++++++-------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/vms/saevm/cchain/tx/export.go b/vms/saevm/cchain/tx/export.go index 9284d0b691b4..d7e61138720e 100644 --- a/vms/saevm/cchain/tx/export.go +++ b/vms/saevm/cchain/tx/export.go @@ -87,13 +87,13 @@ func AccountInputID(address common.Address, nonce uint64) ids.ID { // // Because the total supply of AVAX fits in a uint64, this doesn't matter in // practice and allows for easier fuzzing. -func (e *Export) burned(assetID ids.ID) (uint64, error) { +func (e *Export) burned(avaxAssetID ids.ID) (nAVAX, error) { var ( - burned uint64 + burned nAVAX err error ) for _, in := range e.Ins { - if in.AssetID == assetID { + if in.AssetID == avaxAssetID { burned, err = math.Add(burned, in.Amount) if err != nil { return 0, err @@ -101,7 +101,7 @@ func (e *Export) burned(assetID ids.ID) (uint64, error) { } } for _, out := range e.ExportedOutputs { - if out.Asset.ID == assetID { + if out.Asset.ID == avaxAssetID { burned, err = math.Sub(burned, out.Out.Amount()) if err != nil { return 0, err diff --git a/vms/saevm/cchain/tx/import.go b/vms/saevm/cchain/tx/import.go index 52d0b84a18b2..25be4bc450da 100644 --- a/vms/saevm/cchain/tx/import.go +++ b/vms/saevm/cchain/tx/import.go @@ -74,13 +74,13 @@ func (i *Import) inputIDs() set.Set[ids.ID] { // // Because the total supply of AVAX fits in a uint64, this doesn't matter in // practice and allows for easier fuzzing. -func (i *Import) burned(assetID ids.ID) (uint64, error) { +func (i *Import) burned(avaxAssetID ids.ID) (nAVAX, error) { var ( - burned uint64 + burned nAVAX err error ) for _, in := range i.ImportedInputs { - if in.Asset.ID == assetID { + if in.Asset.ID == avaxAssetID { burned, err = math.Add(burned, in.In.Amount()) if err != nil { return 0, err @@ -88,7 +88,7 @@ func (i *Import) burned(assetID ids.ID) (uint64, error) { } } for _, out := range i.Outs { - if out.AssetID == assetID { + if out.AssetID == avaxAssetID { burned, err = math.Sub(burned, out.Amount) if err != nil { return 0, err diff --git a/vms/saevm/cchain/tx/tx.go b/vms/saevm/cchain/tx/tx.go index d2b131d10b04..dedbeeb624ed 100644 --- a/vms/saevm/cchain/tx/tx.go +++ b/vms/saevm/cchain/tx/tx.go @@ -65,9 +65,9 @@ type Unsigned interface { // inputIDs returns the one-time-use inputs consumed by this transaction. inputIDs() set.Set[ids.ID] - // burned returns the amount of assetID that is consumed but not produced by + // burned returns the amount of AVAX that is consumed but not produced by // this transaction. - burned(assetID ids.ID) (uint64, error) + burned(avaxAssetID ids.ID) (nAVAX, error) // numSigs returns the expected number of signatures required to sign this // transaction. @@ -205,20 +205,25 @@ const X2CRate = 1_000_000_000 var x2cRate = uint256.NewInt(X2CRate) +type ( + nAVAX = uint64 + aAVAX = uint256.Int +) + // ScaleAVAX converts an amount denominated in nAVAX into the C-Chain's aAVAX // denomination. -func ScaleAVAX(nAVAX uint64) uint256.Int { - var aAVAX uint256.Int - aAVAX.SetUint64(nAVAX) - aAVAX.Mul(&aAVAX, x2cRate) - return aAVAX +func ScaleAVAX(v nAVAX) aAVAX { + var r aAVAX + r.SetUint64(v) + r.Mul(&r, x2cRate) + return r } // gasPrice takes in the cost, in nAVAX, and the gas and returns the price per // gas in aAVAX/gas. It assumes gas is non-zero. // // The result is rounded down to the nearest aAVAX/gas. -func gasPrice(cost uint64, gas gas.Gas) uint256.Int { +func gasPrice(cost nAVAX, gas gas.Gas) uint256.Int { var u uint256.Int u.SetUint64(uint64(gas)) From 912625a194e40e3174bddc111d2c9717f3c95df7 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 11:23:37 -0400 Subject: [PATCH 19/27] wip --- vms/saevm/cchain/api_test.go | 13 ++---------- vms/saevm/cchain/hooks_test.go | 31 ++++++++--------------------- vms/saevm/cchain/vm_test.go | 36 +++++++++++++++++++--------------- 3 files changed, 30 insertions(+), 50 deletions(-) diff --git a/vms/saevm/cchain/api_test.go b/vms/saevm/cchain/api_test.go index 08795e1c5aa7..c9c45e43f70b 100644 --- a/vms/saevm/cchain/api_test.go +++ b/vms/saevm/cchain/api_test.go @@ -22,18 +22,9 @@ func TestIssueTxRejectsInvalidTransaction(t *testing.T) { sk := txtest.NewKey(t) // sk is NOT funded. w := newWallet(sk, sut.snowCtx, sut.Client) - const ( - txFee = 50 - exportedAmount = 50 - ) - tx, _ := w.newExportTx( - t, - sut.snowCtx.XChainID, - txFee, - txtest.NewTransferOutput(exportedAmount, sk.Address()), - ) + stx := w.newMinimalTx(t) - err := sut.IssueTx(t.Context(), tx) + err := sut.IssueTx(t.Context(), stx) require.ErrorContainsf(t, err, errIssuingTx.Error(), "%T.IssueTx()", sut.Client) } diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index f155a98fca21..888902d4a597 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -44,31 +44,16 @@ func newBlock(tb testing.TB, number uint64, parent common.Hash, txs ...*tx.Tx) * } func TestAncestorInputIDs(t *testing.T) { - w := newWallet(txtest.NewKey(t), snowtest.Context(t, snowtest.CChainID), nil) - export := func() *tx.Tx { - const ( - txFee = 0 - exportedAmount = 50 - ) - signedExport, _ := w.newExportTx( - t, - snowtest.XChainID, - txFee, - txtest.NewTransferOutput(exportedAmount, w.sk.Address()), - ) - return signedExport - } - var ( + w = newWallet(txtest.NewKey(t), snowtest.Context(t, snowtest.CChainID), nil) settled = common.Hash(ids.GenerateTestID()) - - tx1 = export() - block1 = newBlock(t, 1, settled, tx1) - tx2 = export() - block2 = newBlock(t, 2, block1.Hash(), tx2) - tx3 = export() - block3 = newBlock(t, 3, block2.Hash(), tx3) - block4 = newBlock(t, 4, block3.Hash()) + tx1 = w.newMinimalTx(t) + block1 = newBlock(t, 1, settled, tx1) + tx2 = w.newMinimalTx(t) + block2 = newBlock(t, 2, block1.Hash(), tx2) + tx3 = w.newMinimalTx(t) + block3 = newBlock(t, 3, block2.Hash(), tx3) + block4 = newBlock(t, 4, block3.Hash()) ) tests := []struct { diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 341198a8b637..75b759c8cda1 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -319,6 +319,24 @@ func newWallet(sk *secp256k1.PrivateKey, snowCtx *snow.Context, client *Client) } } +// newMinimalExportTx builds and signs an [tx.Export] sending a single output to +// [snowtest.XChainID]. +func (w *wallet) newMinimalTx(tb testing.TB) *tx.Tx { + tb.Helper() + + const ( + txFee = 1 + exportedAmount = 1 + ) + t, _ := w.newExportTx( + tb, + snowtest.XChainID, + txFee, + txtest.NewTransferOutput(exportedAmount, w.sk.Address()), + ) + return t +} + // newExportTx builds and signs an [tx.Export] sending outputs to // destinationChain. The wallet contributes a single AVAX input from its eth // address with Amount = sum(outputs.Amt) + fee, using its next nonce. @@ -551,23 +569,9 @@ func TestBuildBlockOnProcessing(t *testing.T) { ) })) - newExport := func(w *wallet) *tx.Tx { - const ( - txFee = 50 - exportedAmount = 50 - ) - signedExport, _ := w.newExportTx( - t, - sut.snowCtx.XChainID, - txFee, - txtest.NewTransferOutput(exportedAmount, w.sk.Address()), - ) - return signedExport - } - ctx := t.Context() wA := newWallet(skA, sut.snowCtx, sut.Client) - txA := newExport(wA) + txA := wA.newMinimalTx(t) require.NoErrorf(t, sut.IssueTx(ctx, txA), "%T.IssueTx(txA)", sut.Client) blockA := sut.buildVerify(t, sut.lastAccepted(t)) if diff := cmp.Diff([]*tx.Tx{txA}, blockTxs(t, blockA), txtest.CmpOpt()); diff != "" { @@ -577,7 +581,7 @@ func TestBuildBlockOnProcessing(t *testing.T) { // blockA is verified but not accepted, so txA stays in the mempool and // is presented to blockB's builder as a candidate. wB := newWallet(skB, sut.snowCtx, sut.Client) - txB := newExport(wB) + txB := wB.newMinimalTx(t) require.NoErrorf(t, sut.IssueTx(ctx, txB), "%T.IssueTx(txB)", sut.Client) blockB := sut.buildVerify(t, blockA.ID()) if diff := cmp.Diff([]*tx.Tx{txB}, blockTxs(t, blockB), txtest.CmpOpt()); diff != "" { From fbcb61b64c577188ba2254ee0b56bcd18f10fdf6 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 11:54:22 -0400 Subject: [PATCH 20/27] remove TODO --- vms/saevm/cchain/hooks.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go index c7fb66ee7c46..d428f25b21c9 100644 --- a/vms/saevm/cchain/hooks.go +++ b/vms/saevm/cchain/hooks.go @@ -203,11 +203,9 @@ func (b *builder) BuildHeader(parent *types.Header) (*types.Header, error) { ParentBeaconRoot: new(common.Hash), }, &customtypes.HeaderExtra{ - // TODO(StephenButtolph): Prior to SAE, ExtDataGasUsed included the - // gas cost of the cross-chain transactions. This was used to - // advance the ACP-176 fee state. After SAE, the gas cost is - // accounted for in the executor with [hook.Op.Gas]. We can choose - // to keep populating this field, or just zero it out. + // Prior to SAE, ExtDataGasUsed included the gas cost of the + // cross-chain transactions. However, with SAE, the gas cost is + // included in [types.Header.GasUsed] through with [hook.Op.Gas]. ExtDataGasUsed: big.NewInt(0), // BlockGasCost has been set to 0 since the Granite upgrade. BlockGasCost: big.NewInt(0), From 6473143aa8f2da64c19aabecf17d2baa0e446cba Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 11:57:02 -0400 Subject: [PATCH 21/27] add comment --- vms/saevm/cchain/hooks.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go index d428f25b21c9..f96c35082682 100644 --- a/vms/saevm/cchain/hooks.go +++ b/vms/saevm/cchain/hooks.go @@ -187,6 +187,8 @@ type builder struct { potentialTxs iter.Seq[*hookTx] } +// See [hook.BlockBuilder.BuildHeader] for which fields MUST or MAY be set in +// the returned header. func (b *builder) BuildHeader(parent *types.Header) (*types.Header, error) { // TODO(StephenButtolph): Encode the ACP-176 target excess in the header. // TODO(StephenButtolph): Encode the ACP-183 min price excess in the header. From 1612a0a8727ecea25f08feb508b56361f19c8a8c Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 13:43:10 -0400 Subject: [PATCH 22/27] modern day plumber --- vms/saevm/cchain/api_test.go | 47 +++++++-- vms/saevm/cchain/txpool/txpool_test.go | 39 ++++---- vms/saevm/cchain/vm_test.go | 133 +++++++++---------------- 3 files changed, 110 insertions(+), 109 deletions(-) diff --git a/vms/saevm/cchain/api_test.go b/vms/saevm/cchain/api_test.go index c9c45e43f70b..fbedb35bbe10 100644 --- a/vms/saevm/cchain/api_test.go +++ b/vms/saevm/cchain/api_test.go @@ -4,6 +4,7 @@ package cchain import ( + "context" "testing" "github.com/google/go-cmp/cmp" @@ -15,32 +16,66 @@ import ( "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" ) +// getAllUTXOs drains [Client.GetUTXOs] for addrs by walking pages of size limit +// until a short page signals the end of the result set. +func (c *Client) getAllUTXOs( + ctx context.Context, + tb testing.TB, + sourceChain ids.ID, + limit uint32, + addrs ...ids.ShortID, +) []*avax.UTXO { + tb.Helper() + + var ( + startAddr ids.ShortID + startUTXOID ids.ID + utxos []*avax.UTXO + ) + for { + page, endAddr, endUTXOID, err := c.GetUTXOs( + ctx, + addrs, + sourceChain, + limit, + startAddr, + startUTXOID, + ) + require.NoErrorf(tb, err, "%T.GetUTXOs()", c) + utxos = append(utxos, page...) + if uint64(len(page)) < uint64(limit) { + return utxos + } + startAddr, startUTXOID = endAddr, endUTXOID + } +} + // TestIssueTxRejectsInvalidTransaction asserts that [Client.IssueTx] surfaces // an error from the transaction pool's verification pipeline. func TestIssueTxRejectsInvalidTransaction(t *testing.T) { - sut := newSUT(t) + ctx, sut := newSUT(t) sk := txtest.NewKey(t) // sk is NOT funded. w := newWallet(sk, sut.snowCtx, sut.Client) stx := w.newMinimalTx(t) - err := sut.IssueTx(t.Context(), stx) + err := sut.IssueTx(ctx, stx) require.ErrorContainsf(t, err, errIssuingTx.Error(), "%T.IssueTx()", sut.Client) } // TestGetTxNotFound asserts that [Client.GetTx] surfaces an error when the // requested tx has never been accepted. func TestGetTxNotFound(t *testing.T) { - sut := newSUT(t) + ctx, sut := newSUT(t) - _, _, err := sut.GetTx(t.Context(), ids.GenerateTestID()) + _, _, err := sut.GetTx(ctx, ids.GenerateTestID()) require.ErrorContainsf(t, err, errFetchingTx.Error(), "%T.GetTx()", sut.Client) } // TestGetUTXOsPagination asserts that walking [Client.GetUTXOs] yields each // seeded UTXO exactly once. func TestGetUTXOsPagination(t *testing.T) { - sut := newSUT(t) + ctx, sut := newSUT(t) const numUTXOs uint64 = 5 want := make([]*avax.UTXO, numUTXOs) @@ -53,7 +88,7 @@ func TestGetUTXOsPagination(t *testing.T) { // pageSize=1 stresses the boundary behavior so any off-by-one in the cursor // logic will surface here. const pageSize = 1 - got := getUTXOs(t, sut.Client, snowtest.XChainID, pageSize, addr) + got := sut.Client.getAllUTXOs(ctx, t, snowtest.XChainID, pageSize, addr) if diff := cmp.Diff(want, got, txtest.UTXOCmpOpt()); diff != "" { t.Errorf("paginated UTXOs (-want +got):\n%s", diff) } diff --git a/vms/saevm/cchain/txpool/txpool_test.go b/vms/saevm/cchain/txpool/txpool_test.go index da5fbaccb73e..524ae4797b44 100644 --- a/vms/saevm/cchain/txpool/txpool_test.go +++ b/vms/saevm/cchain/txpool/txpool_test.go @@ -48,7 +48,7 @@ func TestMain(m *testing.M) { // assertEquals asserts that [Pending.Len], [Pending.Has], [Pending.AwaitTxs], // and [Pending.Iter] all match the expected transactions. -func (p *Pending) assertEquals(tb testing.TB, want ...*tx.Tx) { +func (p *Pending) assertEquals(ctx context.Context, tb testing.TB, want ...*tx.Tx) { tb.Helper() assert.Equal(tb, len(want), p.Len(), "Len") @@ -57,9 +57,9 @@ func (p *Pending) assertEquals(tb testing.TB, want ...*tx.Tx) { } if len(want) > 0 { - require.NoError(tb, p.AwaitTxs(tb.Context()), "%T.AwaitTxs()", p) + require.NoError(tb, p.AwaitTxs(ctx), "%T.AwaitTxs()", p) } else { - ctx, cancel := context.WithCancel(tb.Context()) + ctx, cancel := context.WithCancel(ctx) go cancel() err := p.AwaitTxs(ctx) require.ErrorIs(tb, err, context.Canceled, "%T.AwaitTxs()", p) @@ -130,14 +130,15 @@ const maxSize = 4 // newSUT constructs a [Txpool] backed by a [backend]. The pool is closed via // [testing.TB.Cleanup]. -func newSUT(tb testing.TB, state libevm.StateReader) *SUT { +func newSUT(tb testing.TB, state libevm.StateReader) (context.Context, *SUT) { tb.Helper() backend := newBackend(state) - ctx := snowtest.Context(tb, snowtest.CChainID) - ctx.Log = saetest.NewTBLogger(tb, logging.Debug) + snowCtx := snowtest.Context(tb, snowtest.CChainID) + log := saetest.NewTBLogger(tb, logging.Debug) + snowCtx.Log = log pool, err := New( - ctx, + snowCtx, saetest.ChainConfig(), NewPending(), backend, @@ -145,7 +146,7 @@ func newSUT(tb testing.TB, state libevm.StateReader) *SUT { ) require.NoError(tb, err) tb.Cleanup(pool.Close) - return &SUT{ + return log.CancelOnError(tb.Context()), &SUT{ Txpool: pool, backend: backend, } @@ -479,14 +480,14 @@ func TestAdd(t *testing.T) { sdb.SetNonce(addr, nonce) } - sut := newSUT(t, sdb) + ctx, sut := newSUT(t, sdb) for i, raw := range tt.init { require.NoErrorf(t, sut.Add(raw), "%T.Add([%d])", sut, i) } err := sut.Add(tt.toAdd) require.ErrorIsf(t, err, tt.wantErr, "%T.Add(%T)", sut, tt.toAdd) - sut.assertEquals(t, tt.want...) + sut.assertEquals(ctx, t, tt.want...) }) } } @@ -522,35 +523,35 @@ func TestUpdateEvictsConflicts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sdb := newState(t, sk) - sut := newSUT(t, sdb) + ctx, sut := newSUT(t, sdb) require.NoErrorf(t, sut.Add(initTx), "%T.Add(%T)", sut, initTx) - sut.assertEquals(t, initTx) + sut.assertEquals(ctx, t, initTx) sut.markAsExecuted(t, tt.block, sdb) - sut.assertEquals(t, tt.wantPool...) + sut.assertEquals(ctx, t, tt.wantPool...) }) } } func TestStateUpdate(t *testing.T) { noBalance := newState(t) - sut := newSUT(t, noBalance) + ctx, sut := newSUT(t, noBalance) sk := newKey(t) tx := newExport(t, []*secp256k1.PrivateKey{sk}) require.ErrorIsf(t, sut.Add(tx), errVerifyState, "%T.Add()", sut) - sut.assertEquals(t) + sut.assertEquals(ctx, t) hasBalance := newState(t, sk) sut.markAsExecuted(t, newBlock(t), hasBalance) require.NoErrorf(t, sut.Add(tx), "%T.Add()", sut) - sut.assertEquals(t, tx) + sut.assertEquals(ctx, t, tx) } func TestHasUnknown(t *testing.T) { sk := newKey(t) - sut := newSUT(t, newState(t, sk)) + _, sut := newSUT(t, newState(t, sk)) require.Falsef(t, sut.Has(ids.GenerateTestID()), "%T.Has()", sut) raw := newExport(t, []*secp256k1.PrivateKey{sk}) @@ -561,11 +562,11 @@ func TestHasUnknown(t *testing.T) { func TestAwaitTxs(t *testing.T) { synctest.Test(t, func(t *testing.T) { sk := newKey(t) - sut := newSUT(t, newState(t, sk)) + ctx, sut := newSUT(t, newState(t, sk)) done := make(chan error, 1) go func() { - done <- sut.AwaitTxs(t.Context()) + done <- sut.AwaitTxs(ctx) }() synctest.Wait() diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 75b759c8cda1..584a29c4396c 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -71,12 +71,11 @@ type ( // newSUT initializes a cchain [VM], transitions it to [snow.NormalOp], and // mounts its HTTP handlers behind a local [httptest.Server] at the paths // [NewClient] expects. -func newSUT(tb testing.TB, opts ...sutOption) *SUT { +func newSUT(tb testing.TB, opts ...sutOption) (context.Context, *SUT) { tb.Helper() var ( vm = &VM{} - ctx = tb.Context() db = memdb.New() cfg = options.ApplyTo(&sutConfig{ genesis: core.Genesis{ @@ -93,7 +92,8 @@ func newSUT(tb testing.TB, opts ...sutOption) *SUT { memory := atomic.NewMemory(prefixdb.New([]byte("sharedmemory"), db)) snowCtx := snowtest.Context(tb, snowtest.CChainID) snowCtx.SharedMemory = memory.NewSharedMemory(snowtest.CChainID) - snowCtx.Log = saetest.NewTBLogger(tb, logging.Debug) + log := saetest.NewTBLogger(tb, logging.Debug) + snowCtx.Log = log chainDB := prefixdb.New([]byte("chain"), db) @@ -107,6 +107,7 @@ func newSUT(tb testing.TB, opts ...sutOption) *SUT { }, } + ctx := log.CancelOnError(tb.Context()) require.NoErrorf(tb, vm.Initialize( ctx, snowCtx, @@ -135,7 +136,7 @@ func newSUT(tb testing.TB, opts ...sutOption) *SUT { server := httptest.NewServer(mux) tb.Cleanup(server.Close) - return &SUT{ + return ctx, &SUT{ VM: vm, Client: NewClient(server.URL), snowCtx: snowCtx, @@ -231,19 +232,19 @@ func (s *SUT) assertAccount(tb testing.TB, addr common.Address, wantNonce uint64 // issueAndExecute submits t through [Client.IssueTx] and drives the consensus // loop to produce, accept, and execute the next block, which is returned. -func (s *SUT) issueAndExecute(tb testing.TB, t *tx.Tx) *blocks.Block { +func (s *SUT) issueAndExecute(ctx context.Context, tb testing.TB, t *tx.Tx) *blocks.Block { tb.Helper() - require.NoErrorf(tb, s.IssueTx(tb.Context(), t), "%T.IssueTx()", s.Client) - return s.runConsensusLoop(tb) + require.NoErrorf(tb, s.IssueTx(ctx, t), "%T.IssueTx()", s.Client) + return s.runConsensusLoop(ctx, tb) } // assertTxAccepted asserts that [Client.GetTx] returns the given tx at the // given block height. -func (s *SUT) assertTxAccepted(tb testing.TB, want *tx.Tx, wantHeight uint64) { +func (s *SUT) assertTxAccepted(ctx context.Context, tb testing.TB, want *tx.Tx, wantHeight uint64) { tb.Helper() - got, gotHeight, err := s.GetTx(tb.Context(), want.ID()) + got, gotHeight, err := s.GetTx(ctx, want.ID()) require.NoErrorf(tb, err, "%T.GetTx()", s.Client) if diff := cmp.Diff(want, got, txtest.CmpOpt()); diff != "" { tb.Errorf("%T.GetTx() (-want +got):\n%s", s.Client, diff) @@ -253,38 +254,38 @@ func (s *SUT) assertTxAccepted(tb testing.TB, want *tx.Tx, wantHeight uint64) { // runConsensusLoop builds a block on top of the last-accepted block, drives it // through verify+accept, and waits until it has been executed. -func (s *SUT) runConsensusLoop(tb testing.TB) *blocks.Block { +func (s *SUT) runConsensusLoop(ctx context.Context, tb testing.TB) *blocks.Block { tb.Helper() - blk := s.buildVerifyAccept(tb) - require.NoErrorf(tb, blk.WaitUntilExecuted(tb.Context()), "%T.WaitUntilExecuted()", blk) + blk := s.buildVerifyAccept(ctx, tb) + require.NoErrorf(tb, blk.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted()", blk) return blk } // buildVerifyAccept builds, verifies, and accepts a block on top of the // last-accepted block. -func (s *SUT) buildVerifyAccept(tb testing.TB) *blocks.Block { +func (s *SUT) buildVerifyAccept(ctx context.Context, tb testing.TB) *blocks.Block { tb.Helper() - blk := s.buildVerify(tb, s.lastAccepted(tb)) - require.NoErrorf(tb, s.AcceptBlock(tb.Context(), blk), "%T.AcceptBlock()", s.VM) + lastAccepted := s.lastAccepted(ctx, tb) + blk := s.buildVerify(ctx, tb, lastAccepted) + require.NoErrorf(tb, s.AcceptBlock(ctx, blk), "%T.AcceptBlock()", s.VM) return blk } // lastAccepted returns the ID of the last-accepted block. -func (s *SUT) lastAccepted(tb testing.TB) ids.ID { +func (s *SUT) lastAccepted(ctx context.Context, tb testing.TB) ids.ID { tb.Helper() - id, err := s.LastAccepted(tb.Context()) + id, err := s.LastAccepted(ctx) require.NoErrorf(tb, err, "%T.LastAccepted()", s.VM) return id } // buildVerify builds and verifies a block on top of preferenceID. -func (s *SUT) buildVerify(tb testing.TB, preferenceID ids.ID) *blocks.Block { +func (s *SUT) buildVerify(ctx context.Context, tb testing.TB, preferenceID ids.ID) *blocks.Block { tb.Helper() - ctx := tb.Context() // TODO(StephenButtolph): When implementing Warp, we will need to provide // meaningful block contexts. var blockCtx *block.Context @@ -381,6 +382,7 @@ func (w *wallet) newExportTx( // by the wallet, crediting the total imported (minus fee) to `to` on the // C-Chain. func (w *wallet) newImportTx( + ctx context.Context, tb testing.TB, sourceChain ids.ID, to common.Address, @@ -388,11 +390,10 @@ func (w *wallet) newImportTx( ) (*tx.Tx, *tx.Import) { tb.Helper() - utxos := w.getUTXOs(tb, sourceChain) - var ( avaxAssetID = w.snowCtx.AVAXAssetID importedAVAX uint64 + utxos = w.client.getAllUTXOs(ctx, tb, sourceChain, maxGetUTXOsLimit, w.sk.Address()) inputs = make([]*avax.TransferableInput, 0, len(utxos)) ) for _, utxo := range utxos { @@ -431,47 +432,6 @@ func (w *wallet) newImportTx( return w.sign(tb, imp, len(inputs)), imp } -// getUTXOs returns every UTXO controlled by the wallet that has been exported -// to this chain from sourceChain. -func (w *wallet) getUTXOs(tb testing.TB, sourceChain ids.ID) []*avax.UTXO { - tb.Helper() - return getUTXOs(tb, w.client, sourceChain, maxGetUTXOsLimit, w.sk.Address()) -} - -// getUTXOs drains [Client.GetUTXOs] for addrs by walking pages of size limit -// until a short page signals the end of the result set. -func getUTXOs( - tb testing.TB, - client *Client, - sourceChain ids.ID, - limit uint32, - addrs ...ids.ShortID, -) []*avax.UTXO { - tb.Helper() - - var ( - startAddr ids.ShortID - startUTXOID ids.ID - utxos []*avax.UTXO - ) - for { - page, endAddr, endUTXOID, err := client.GetUTXOs( - tb.Context(), - addrs, - sourceChain, - limit, - startAddr, - startUTXOID, - ) - require.NoErrorf(tb, err, "%T.GetUTXOs()", client) - utxos = append(utxos, page...) - if uint64(len(page)) < uint64(limit) { - return utxos - } - startAddr, startUTXOID = endAddr, endUTXOID - } -} - // sign wraps u in a [tx.Tx] with numCreds copies of a single-sig credential // over u. func (w *wallet) sign(tb testing.TB, u tx.Unsigned, numCreds int) *tx.Tx { @@ -508,7 +468,7 @@ func addNAVAX(tb testing.TB, balance uint256.Int, nAVAXDelta int64) uint256.Int func TestExport(t *testing.T) { sk := txtest.NewKey(t) sender := sk.EthAddress() - sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + ctx, sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { c.genesis.Alloc = saetest.MaxAllocFor(sender) })) @@ -525,16 +485,19 @@ func TestExport(t *testing.T) { ) initialBalance := sut.balance(t, sender) - blk := sut.issueAndExecute(t, signedExport) - sut.assertTxAccepted(t, signedExport, blk.NumberU64()) - const amountBurned = exportedAmount + txFee - sut.assertAccount(t, sender, 1, addNAVAX(t, initialBalance, -amountBurned)) + blk := sut.issueAndExecute(ctx, t, signedExport) + sut.assertTxAccepted(ctx, t, signedExport, blk.NumberU64()) + const ( + nonce = 1 + amountBurned = exportedAmount + txFee + ) + sut.assertAccount(t, sender, nonce, addNAVAX(t, initialBalance, -amountBurned)) sut.assertUTXOsExist(t, sut.snowCtx.XChainID, txtest.ExportedUTXOs(signedExport.ID(), export)...) } // TestImport exercises the cchain VM end-to-end with an Import tx. func TestImport(t *testing.T) { - sut := newSUT(t) + ctx, sut := newSUT(t) const utxoAmount = 100 sk := txtest.NewKey(t) @@ -547,12 +510,15 @@ func TestImport(t *testing.T) { w := newWallet(sk, sut.snowCtx, sut.Client) receiver := txtest.NewKey(t).EthAddress() const txFee = 50 - signedImport, _ := w.newImportTx(t, sut.snowCtx.XChainID, receiver, txFee) + signedImport, _ := w.newImportTx(ctx, t, sut.snowCtx.XChainID, receiver, txFee) - blk := sut.issueAndExecute(t, signedImport) - sut.assertTxAccepted(t, signedImport, blk.NumberU64()) - const amountMinted = utxoAmount - txFee - sut.assertAccount(t, receiver, 0, tx.ScaleAVAX(amountMinted)) + blk := sut.issueAndExecute(ctx, t, signedImport) + sut.assertTxAccepted(ctx, t, signedImport, blk.NumberU64()) + const ( + nonce = 0 + amountMinted = utxoAmount - txFee + ) + sut.assertAccount(t, receiver, nonce, tx.ScaleAVAX(amountMinted)) } // TestBuildBlockOnProcessing verifies that the block builder excludes a mempool @@ -562,18 +528,17 @@ func TestBuildBlockOnProcessing(t *testing.T) { skA = txtest.NewKey(t) skB = txtest.NewKey(t) ) - sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + ctx, sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { c.genesis.Alloc = saetest.MaxAllocFor( skA.EthAddress(), skB.EthAddress(), ) })) - ctx := t.Context() wA := newWallet(skA, sut.snowCtx, sut.Client) txA := wA.newMinimalTx(t) require.NoErrorf(t, sut.IssueTx(ctx, txA), "%T.IssueTx(txA)", sut.Client) - blockA := sut.buildVerify(t, sut.lastAccepted(t)) + blockA := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) if diff := cmp.Diff([]*tx.Tx{txA}, blockTxs(t, blockA), txtest.CmpOpt()); diff != "" { t.Errorf("%T txs (-want +got):\n%s", blockA, diff) } @@ -583,7 +548,7 @@ func TestBuildBlockOnProcessing(t *testing.T) { wB := newWallet(skB, sut.snowCtx, sut.Client) txB := wB.newMinimalTx(t) require.NoErrorf(t, sut.IssueTx(ctx, txB), "%T.IssueTx(txB)", sut.Client) - blockB := sut.buildVerify(t, blockA.ID()) + blockB := sut.buildVerify(ctx, t, blockA.ID()) if diff := cmp.Diff([]*tx.Tx{txB}, blockTxs(t, blockB), txtest.CmpOpt()); diff != "" { t.Errorf("%T txs (-want +got):\n%s", blockB, diff) } @@ -592,8 +557,8 @@ func TestBuildBlockOnProcessing(t *testing.T) { require.NoErrorf(t, sut.AcceptBlock(ctx, blockB), "%T.AcceptBlock(blockB)", sut.VM) require.NoErrorf(t, blockB.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(blockB)", blockB) - sut.assertTxAccepted(t, txA, blockA.NumberU64()) - sut.assertTxAccepted(t, txB, blockB.NumberU64()) + sut.assertTxAccepted(ctx, t, txA, blockA.NumberU64()) + sut.assertTxAccepted(ctx, t, txB, blockB.NumberU64()) } // blockTxs returns every cross-chain tx encoded in the block. @@ -611,7 +576,7 @@ func TestDebugTraceDoesNotApplyAtomicState(t *testing.T) { ethWallet := saetest.NewUNSAFEWallet(t, 1, types.LatestSigner(saetest.ChainConfig())) ethSender := ethWallet.Addresses()[0] exportKey := txtest.NewKey(t) - sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + ctx, sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { c.genesis.Alloc = saetest.MaxAllocFor( ethSender, exportKey.EthAddress(), @@ -626,7 +591,7 @@ func TestDebugTraceDoesNotApplyAtomicState(t *testing.T) { GasPrice: big.NewInt(1), }) rpc := sut.GethRPCBackends() - require.NoErrorf(t, rpc.SendTx(t.Context(), tracedTx), "%T.SendTx(%#x)", rpc, tracedTx.Hash()) + require.NoErrorf(t, rpc.SendTx(ctx, tracedTx), "%T.SendTx(%#x)", rpc, tracedTx.Hash()) // Export gives us observable external state. w := newWallet(exportKey, sut.snowCtx, sut.Client) @@ -640,15 +605,15 @@ func TestDebugTraceDoesNotApplyAtomicState(t *testing.T) { txFee, txtest.NewTransferOutput(exportedAmount, exportKey.Address()), ) - require.NoErrorf(t, sut.IssueTx(t.Context(), signedExport), "%T.IssueTx()", sut.Client) + require.NoErrorf(t, sut.IssueTx(ctx, signedExport), "%T.IssueTx()", sut.Client) - blk := sut.buildVerify(t, sut.lastAccepted(t)) + blk := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) require.Equalf(t, types.Transactions{tracedTx}, blk.Transactions(), "%T.Transactions()", blk) if diff := cmp.Diff([]*tx.Tx{signedExport}, blockTxs(t, blk), txtest.CmpOpt()); diff != "" { t.Errorf("%T txs (-want +got):\n%s", blk, diff) } - _, _, _, release, err := rpc.StateAtTransaction(t.Context(), blk.EthBlock(), 0, 0) + _, _, _, release, err := rpc.StateAtTransaction(ctx, blk.EthBlock(), 0, 0) require.NoErrorf(t, err, "%T.StateAtTransaction(...)", rpc) defer release() From 71a1167ab75d7ed7d6ef0ae202d74865950e59e8 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 14:05:57 -0400 Subject: [PATCH 23/27] nit --- vms/saevm/cchain/vm_test.go | 55 +++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 584a29c4396c..ad9a0cb5b104 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -524,41 +524,36 @@ func TestImport(t *testing.T) { // TestBuildBlockOnProcessing verifies that the block builder excludes a mempool // candidate whose inputs were already consumed by an unsettled ancestor block. func TestBuildBlockOnProcessing(t *testing.T) { - var ( - skA = txtest.NewKey(t) - skB = txtest.NewKey(t) - ) + keys := make([]*secp256k1.PrivateKey, 2) + for i := range keys { + keys[i] = txtest.NewKey(t) + } ctx, sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { - c.genesis.Alloc = saetest.MaxAllocFor( - skA.EthAddress(), - skB.EthAddress(), - ) + addrs := make([]common.Address, len(keys)) + for i, sk := range keys { + addrs[i] = sk.EthAddress() + } + c.genesis.Alloc = saetest.MaxAllocFor(addrs...) })) - wA := newWallet(skA, sut.snowCtx, sut.Client) - txA := wA.newMinimalTx(t) - require.NoErrorf(t, sut.IssueTx(ctx, txA), "%T.IssueTx(txA)", sut.Client) - blockA := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) - if diff := cmp.Diff([]*tx.Tx{txA}, blockTxs(t, blockA), txtest.CmpOpt()); diff != "" { - t.Errorf("%T txs (-want +got):\n%s", blockA, diff) - } + blocks := make([]*blocks.Block, len(keys)) + for i, sk := range keys { + stx := newWallet(sk, sut.snowCtx, sut.Client).newMinimalTx(t) + require.NoErrorf(t, sut.IssueTx(ctx, stx), "%T.IssueTx(tx)", sut.Client) - // blockA is verified but not accepted, so txA stays in the mempool and - // is presented to blockB's builder as a candidate. - wB := newWallet(skB, sut.snowCtx, sut.Client) - txB := wB.newMinimalTx(t) - require.NoErrorf(t, sut.IssueTx(ctx, txB), "%T.IssueTx(txB)", sut.Client) - blockB := sut.buildVerify(ctx, t, blockA.ID()) - if diff := cmp.Diff([]*tx.Tx{txB}, blockTxs(t, blockB), txtest.CmpOpt()); diff != "" { - t.Errorf("%T txs (-want +got):\n%s", blockB, diff) + block := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) + if diff := cmp.Diff([]*tx.Tx{stx}, blockTxs(t, block), txtest.CmpOpt()); diff != "" { + t.Errorf("%T txs (-want +got):\n%s", block, diff) + } + blocks[i] = block + } + for i, block := range blocks { + require.NoErrorf(t, sut.AcceptBlock(ctx, block), "%T.AcceptBlock(%d)", sut.VM, i) + require.NoErrorf(t, block.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(%d)", block, i) + for _, tx := range blockTxs(t, block) { + sut.assertTxAccepted(ctx, t, tx, block.NumberU64()) + } } - - require.NoErrorf(t, sut.AcceptBlock(ctx, blockA), "%T.AcceptBlock(blockA)", sut.VM) - require.NoErrorf(t, sut.AcceptBlock(ctx, blockB), "%T.AcceptBlock(blockB)", sut.VM) - require.NoErrorf(t, blockB.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(blockB)", blockB) - - sut.assertTxAccepted(ctx, t, txA, blockA.NumberU64()) - sut.assertTxAccepted(ctx, t, txB, blockB.NumberU64()) } // blockTxs returns every cross-chain tx encoded in the block. From d283531242313b73ed7f5d8645bda9b0d52687bd Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 14:18:11 -0400 Subject: [PATCH 24/27] oops --- vms/saevm/cchain/vm_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index ad9a0cb5b104..308dc9a1c2c3 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -536,16 +536,20 @@ func TestBuildBlockOnProcessing(t *testing.T) { c.genesis.Alloc = saetest.MaxAllocFor(addrs...) })) - blocks := make([]*blocks.Block, len(keys)) + var ( + preference = sut.lastAccepted(ctx, t) + blocks = make([]*blocks.Block, len(keys)) + ) for i, sk := range keys { stx := newWallet(sk, sut.snowCtx, sut.Client).newMinimalTx(t) require.NoErrorf(t, sut.IssueTx(ctx, stx), "%T.IssueTx(tx)", sut.Client) - block := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) + block := sut.buildVerify(ctx, t, preference) if diff := cmp.Diff([]*tx.Tx{stx}, blockTxs(t, block), txtest.CmpOpt()); diff != "" { t.Errorf("%T txs (-want +got):\n%s", block, diff) } blocks[i] = block + preference = block.ID() } for i, block := range blocks { require.NoErrorf(t, sut.AcceptBlock(ctx, block), "%T.AcceptBlock(%d)", sut.VM, i) From 14fb052438bc6c202cff211b56abc8b43f3c72c1 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 15:02:24 -0400 Subject: [PATCH 25/27] add ethclient --- vms/saevm/cchain/BUILD.bazel | 3 +++ vms/saevm/cchain/vm_test.go | 33 +++++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/vms/saevm/cchain/BUILD.bazel b/vms/saevm/cchain/BUILD.bazel index 173c0ee056ad..e7c59c44b3c8 100644 --- a/vms/saevm/cchain/BUILD.bazel +++ b/vms/saevm/cchain/BUILD.bazel @@ -95,14 +95,17 @@ go_test( "//vms/saevm/blocks", "//vms/saevm/cchain/tx", "//vms/saevm/cchain/tx/txtest", + "//vms/saevm/cmputils", "//vms/saevm/params", "//vms/saevm/saetest", "//vms/secp256k1fx", "@com_github_ava_labs_libevm//common", "@com_github_ava_labs_libevm//core", "@com_github_ava_labs_libevm//core/types", + "@com_github_ava_labs_libevm//ethclient", "@com_github_ava_labs_libevm//libevm/options", "@com_github_ava_labs_libevm//params", + "@com_github_ava_labs_libevm//rpc", "@com_github_google_go_cmp//cmp", "@com_github_holiman_uint256//:uint256", "@com_github_stretchr_testify//assert", diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 308dc9a1c2c3..026bb52735ac 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -14,6 +14,7 @@ import ( "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core" "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/ethclient" "github.com/ava-labs/libevm/libevm/options" "github.com/google/go-cmp/cmp" "github.com/holiman/uint256" @@ -38,12 +39,14 @@ import ( "github.com/ava-labs/avalanchego/vms/saevm/blocks" "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" + "github.com/ava-labs/avalanchego/vms/saevm/cmputils" "github.com/ava-labs/avalanchego/vms/saevm/saetest" "github.com/ava-labs/avalanchego/vms/secp256k1fx" snowcommon "github.com/ava-labs/avalanchego/snow/engine/common" saeparams "github.com/ava-labs/avalanchego/vms/saevm/params" ethparams "github.com/ava-labs/libevm/params" + ethrpc "github.com/ava-labs/libevm/rpc" ) func TestMain(m *testing.M) { @@ -57,8 +60,9 @@ type SUT struct { *VM *Client - snowCtx *snow.Context - memory *atomic.Memory + snowCtx *snow.Context + memory *atomic.Memory + ethclient *ethclient.Client } type ( @@ -136,11 +140,18 @@ func newSUT(tb testing.TB, opts ...sutOption) (context.Context, *SUT) { server := httptest.NewServer(mux) tb.Cleanup(server.Close) + const wsHTTPPath = cchainHTTPPrefix + "/ws" + wsURI := "ws://" + server.Listener.Addr().String() + wsHTTPPath + ethRPCClient, err := ethrpc.Dial(wsURI) + require.NoErrorf(tb, err, "rpc.Dial(%s)", wsURI) + tb.Cleanup(ethRPCClient.Close) + return ctx, &SUT{ - VM: vm, - Client: NewClient(server.URL), - snowCtx: snowCtx, - memory: memory, + VM: vm, + Client: NewClient(server.URL), + snowCtx: snowCtx, + memory: memory, + ethclient: ethclient.NewClient(ethRPCClient), } } @@ -589,8 +600,7 @@ func TestDebugTraceDoesNotApplyAtomicState(t *testing.T) { Gas: ethparams.TxGas, GasPrice: big.NewInt(1), }) - rpc := sut.GethRPCBackends() - require.NoErrorf(t, rpc.SendTx(ctx, tracedTx), "%T.SendTx(%#x)", rpc, tracedTx.Hash()) + require.NoErrorf(t, sut.ethclient.SendTransaction(ctx, tracedTx), "%T.SendTransaction(%#x)", sut.ethclient, tracedTx.Hash()) // Export gives us observable external state. w := newWallet(exportKey, sut.snowCtx, sut.Client) @@ -607,11 +617,14 @@ func TestDebugTraceDoesNotApplyAtomicState(t *testing.T) { require.NoErrorf(t, sut.IssueTx(ctx, signedExport), "%T.IssueTx()", sut.Client) blk := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) - require.Equalf(t, types.Transactions{tracedTx}, blk.Transactions(), "%T.Transactions()", blk) + if diff := cmp.Diff(types.Transactions{tracedTx}, blk.Transactions(), cmputils.TransactionsByHash()); diff != "" { + t.Errorf("%T eth txs (-want +got):\n%s", blk, diff) + } if diff := cmp.Diff([]*tx.Tx{signedExport}, blockTxs(t, blk), txtest.CmpOpt()); diff != "" { - t.Errorf("%T txs (-want +got):\n%s", blk, diff) + t.Errorf("%T cross-chain txs (-want +got):\n%s", blk, diff) } + rpc := sut.GethRPCBackends() _, _, _, release, err := rpc.StateAtTransaction(ctx, blk.EthBlock(), 0, 0) require.NoErrorf(t, err, "%T.StateAtTransaction(...)", rpc) defer release() From 745e668a8780150d7a0729027c15549df5e48597 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 22 May 2026 16:41:32 -0400 Subject: [PATCH 26/27] ok --- vms/saevm/cchain/api.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vms/saevm/cchain/api.go b/vms/saevm/cchain/api.go index 816a992cebdf..649c23bab932 100644 --- a/vms/saevm/cchain/api.go +++ b/vms/saevm/cchain/api.go @@ -43,8 +43,8 @@ type service struct { func newService( ctx *snow.Context, - txpool *txpool.Txpool, - state *state.State, + pool *txpool.Txpool, + db *state.State, ) (*service, error) { chainAlias, err := ctx.BCLookup.PrimaryAlias(ctx.ChainID) if err != nil { @@ -59,8 +59,8 @@ func newService( return &service{ ctx: ctx, - txpool: txpool, - state: state, + txpool: pool, + state: db, chainAlias: chainAlias, hrp: hrp, From c2c3ec3105f7f1030c43f17127d02432215542e4 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 26 May 2026 12:22:23 -0400 Subject: [PATCH 27/27] nits --- utils/set/set_test.go | 2 +- vms/saevm/cchain/hooks.go | 4 ++-- vms/saevm/cchain/hooks_test.go | 24 +++++++++++++++--------- vms/saevm/cchain/vm_test.go | 2 +- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/utils/set/set_test.go b/utils/set/set_test.go index d0f05ba4b238..7886ad92782b 100644 --- a/utils/set/set_test.go +++ b/utils/set/set_test.go @@ -124,7 +124,7 @@ func TestUnionOf(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := UnionOf(tt.elements...) - require.Equal(t, tt.expected, s) + require.Equalf(t, tt.expected, s, "UnionOf(%v)", tt.elements) }) } } diff --git a/vms/saevm/cchain/hooks.go b/vms/saevm/cchain/hooks.go index f96c35082682..fa8e591f053a 100644 --- a/vms/saevm/cchain/hooks.go +++ b/vms/saevm/cchain/hooks.go @@ -229,7 +229,7 @@ func (b *builder) BuildHeader(parent *types.Header) (*types.Header, error) { // valid with respect to the worst-case state. func (b *builder) PotentialEndOfBlockOps( ctx context.Context, - header *types.Header, + building *types.Header, settledHash common.Hash, source saetypes.BlockSource, ) iter.Seq[*hookTx] { @@ -239,7 +239,7 @@ func (b *builder) PotentialEndOfBlockOps( // between the block we are building and the last executed block. Since // we know the settled block has been executed, we use that as our // reference point. - inputs, err := ancestorInputIDs(header, settledHash, source) + inputs, err := ancestorInputIDs(building, settledHash, source) if err != nil { b.ctx.Log.Error("failed to get ancestor input IDs", zap.Error(err), diff --git a/vms/saevm/cchain/hooks_test.go b/vms/saevm/cchain/hooks_test.go index 888902d4a597..c6f003b462e2 100644 --- a/vms/saevm/cchain/hooks_test.go +++ b/vms/saevm/cchain/hooks_test.go @@ -46,9 +46,9 @@ func newBlock(tb testing.TB, number uint64, parent common.Hash, txs ...*tx.Tx) * func TestAncestorInputIDs(t *testing.T) { var ( w = newWallet(txtest.NewKey(t), snowtest.Context(t, snowtest.CChainID), nil) - settled = common.Hash(ids.GenerateTestID()) + genesis = common.Hash(ids.GenerateTestID()) tx1 = w.newMinimalTx(t) - block1 = newBlock(t, 1, settled, tx1) + block1 = newBlock(t, 1, genesis, tx1) tx2 = w.newMinimalTx(t) block2 = newBlock(t, 2, block1.Hash(), tx2) tx3 = w.newMinimalTx(t) @@ -64,25 +64,31 @@ func TestAncestorInputIDs(t *testing.T) { wantErr error }{ { - name: "empty range", + name: "empty_range", header: block1.Header(), - settled: settled, + settled: genesis, want: nil, }, { - name: "single ancestor", + name: "single_ancestor", header: block2.Header(), - settled: settled, + settled: genesis, want: tx1.InputIDs(), }, { - name: "multiple ancestors", + name: "multiple_ancestors", header: block4.Header(), - settled: settled, + settled: genesis, want: set.UnionOf(tx1.InputIDs(), tx2.InputIDs(), tx3.InputIDs()), }, { - name: "missing block", + name: "stops_at_settled", + header: block4.Header(), + settled: block1.Hash(), + want: set.UnionOf(tx2.InputIDs(), tx3.InputIDs()), + }, + { + name: "missing_block", header: block2.Header(), settled: common.Hash(ids.GenerateTestID()), // never matches wantErr: errMissingBlock, diff --git a/vms/saevm/cchain/vm_test.go b/vms/saevm/cchain/vm_test.go index 026bb52735ac..b7066b52f0e9 100644 --- a/vms/saevm/cchain/vm_test.go +++ b/vms/saevm/cchain/vm_test.go @@ -179,7 +179,7 @@ func (s *SUT) assertUTXOsExist(tb testing.TB, peerChainID ids.ID, want ...*avax. } // assertUTXOsMissing asserts that the shared memory between peerChainID and the -// C-Chain does not contain any of the expected UTXOs. +// C-Chain does not contain any of the unwanted UTXOs. func (s *SUT) assertUTXOsMissing(tb testing.TB, peerChainID ids.ID, unwanted ...*avax.UTXO) { tb.Helper()