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/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..7886ad92782b 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.Equalf(t, tt.expected, s, "UnionOf(%v)", tt.elements) + }) + } +} + func TestSetClear(t *testing.T) { require := require.New(t) diff --git a/vms/saevm/cchain/BUILD.bazel b/vms/saevm/cchain/BUILD.bazel index fa721a4fadad..e7c59c44b3c8 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,104 @@ 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", + "//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/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", + "@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..649c23bab932 --- /dev/null +++ b/vms/saevm/cchain/api.go @@ -0,0 +1,383 @@ +// 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, + pool *txpool.Txpool, + db *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: pool, + state: db, + + 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.UTXOs = []string{} + 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..fbedb35bbe10 --- /dev/null +++ b/vms/saevm/cchain/api_test.go @@ -0,0 +1,95 @@ +// Copyright (C) 2019, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cchain + +import ( + "context" + "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" +) + +// 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) { + 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(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) { + ctx, sut := newSUT(t) + + _, _, 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) { + ctx, sut := newSUT(t) + + const numUTXOs uint64 = 5 + want := make([]*avax.UTXO, numUTXOs) + addr := txtest.NewKey(t).Address() + for i := range numUTXOs { + want[i] = txtest.NewUTXO(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 := 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/hooks.go b/vms/saevm/cchain/hooks.go new file mode 100644 index 000000000000..fa8e591f053a --- /dev/null +++ b/vms/saevm/cchain/hooks.go @@ -0,0 +1,370 @@ +// 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" + + 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" +) + +var _ hook.PointsG[*hookTx] = (*hooks)(nil) + +type hooks struct { + builder + state *cchainstate.State +} + +func newHooks( + ctx *snow.Context, + state *cchainstate.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, + 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()) + return &builder{ + h.ctx, + func() time.Time { + return now + }, + slices.Values(txs), + }, 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 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. + // 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{ + // 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), + // TODO(StephenButtolph): Encode the millisecond timestamp. + TimeMilliseconds: new(uint64), + // TODO(StephenButtolph): Encode the min-delay excess. + MinDelayExcess: new(acp226.DelayExcess), + }, + ), 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, + building *types.Header, + settledHash common.Hash, + source saetypes.BlockSource, +) iter.Seq[*hookTx] { + return func(yield func(*hookTx) bool) { + // 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(building, settledHash, source) + if err != nil { + b.ctx.Log.Error("failed to get ancestor input IDs", + zap.Error(err), + ) + return + } + + for t := range b.potentialTxs { + if inputs.Overlaps(t.inputs) { + b.ctx.Log.Debug("tx consumes previously consumed inputs", + zap.Stringer("txID", t.id), + ) + 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), + zap.Error(err), + ) + 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), + 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..c6f003b462e2 --- /dev/null +++ b/vms/saevm/cchain/hooks_test.go @@ -0,0 +1,113 @@ +// 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" +) + +// 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) { + var ( + w = newWallet(txtest.NewKey(t), snowtest.Context(t, snowtest.CChainID), nil) + genesis = common.Hash(ids.GenerateTestID()) + tx1 = w.newMinimalTx(t) + block1 = newBlock(t, 1, genesis, 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 { + name string + header *types.Header + settled common.Hash + want set.Set[ids.ID] + wantErr error + }{ + { + name: "empty_range", + header: block1.Header(), + settled: genesis, + want: nil, + }, + { + name: "single_ancestor", + header: block2.Header(), + settled: genesis, + want: tx1.InputIDs(), + }, + { + name: "multiple_ancestors", + header: block4.Header(), + settled: genesis, + want: set.UnionOf(tx1.InputIDs(), tx2.InputIDs(), tx3.InputIDs()), + }, + { + 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, + }, + } + 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()") + }) + } +} 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..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 @@ -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..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 @@ -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..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. @@ -199,30 +199,35 @@ 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 +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)) - 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..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: MarshalUTXO(t, validUTXO), + Value: txtest.MarshalUTXO(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..efa14e88dade 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) } + +// MarshalUTXO returns the canonical binary format of utxo. +func MarshalUTXO(tb testing.TB, utxo *avax.UTXO) []byte { + tb.Helper() + + b, err := tx.MarshalUTXO(utxo) + require.NoError(tb, err, "tx.MarshalUTXO()") + return b +} + +// 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) + 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/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.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..b7066b52f0e9 --- /dev/null +++ b/vms/saevm/cchain/vm_test.go @@ -0,0 +1,636 @@ +// 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/ethclient" + "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" + "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/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) { + 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 + ethclient *ethclient.Client +} + +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) (context.Context, *SUT) { + tb.Helper() + + var ( + vm = &VM{} + 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) + log := saetest.NewTBLogger(tb, logging.Debug) + snowCtx.Log = log + + 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 + }, + } + + ctx := log.CancelOnError(tb.Context()) + 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) + + 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, + ethclient: ethclient.NewClient(ethRPCClient), + } +} + +// 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.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) + } +} + +// assertUTXOsMissing asserts that the shared memory between peerChainID and the +// 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() + + 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) { + tb.Helper() + + elems := make([]*atomic.Element, len(utxos)) + for i, utxo := range utxos { + inputID := utxo.InputID() + e := &atomic.Element{ + Key: inputID[:], + Value: txtest.MarshalUTXO(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) +} + +// 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() + + 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 +// loop to produce, accept, and execute the next block, which is returned. +func (s *SUT) issueAndExecute(ctx context.Context, tb testing.TB, t *tx.Tx) *blocks.Block { + tb.Helper() + + 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(ctx context.Context, tb testing.TB, want *tx.Tx, wantHeight uint64) { + tb.Helper() + + 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) + } + 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(ctx context.Context, tb testing.TB) *blocks.Block { + tb.Helper() + + 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(ctx context.Context, tb testing.TB) *blocks.Block { + tb.Helper() + + 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(ctx context.Context, tb testing.TB) ids.ID { + tb.Helper() + + 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(ctx context.Context, tb testing.TB, preferenceID ids.ID) *blocks.Block { + tb.Helper() + + // 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, + } +} + +// 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. +func (w *wallet) newExportTx( + tb testing.TB, + destinationChain ids.ID, + fee uint64, + outputs ...*secp256k1fx.TransferOutput, +) (*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( + ctx context.Context, + tb testing.TB, + sourceChain ids.ID, + to common.Address, + fee uint64, +) (*tx.Tx, *tx.Import) { + tb.Helper() + + 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 { + 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 +} + +// 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() + ctx, sut := newSUT(t, options.Func[sutConfig](func(c *sutConfig) { + c.genesis.Alloc = saetest.MaxAllocFor(sender) + })) + + w := newWallet(sk, sut.snowCtx, sut.Client) + const ( + txFee = 50 + exportedAmount = 50 + ) + signedExport, export := w.newExportTx( + t, + sut.snowCtx.XChainID, + txFee, + txtest.NewTransferOutput(exportedAmount, sk.Address()), + ) + + initialBalance := sut.balance(t, sender) + 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) { + ctx, 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(ctx, t, sut.snowCtx.XChainID, receiver, txFee) + + 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 +// candidate whose inputs were already consumed by an unsettled ancestor block. +func TestBuildBlockOnProcessing(t *testing.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) { + addrs := make([]common.Address, len(keys)) + for i, sk := range keys { + addrs[i] = sk.EthAddress() + } + c.genesis.Alloc = saetest.MaxAllocFor(addrs...) + })) + + 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, 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) + require.NoErrorf(t, block.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted(%d)", block, i) + for _, tx := range blockTxs(t, block) { + sut.assertTxAccepted(ctx, t, tx, block.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 +} + +// 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) + ctx, 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), + }) + 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) + const ( + txFee = 50 + exportedAmount = 50 + ) + signedExport, export := w.newExportTx( + t, + sut.snowCtx.XChainID, + txFee, + txtest.NewTransferOutput(exportedAmount, exportKey.Address()), + ) + require.NoErrorf(t, sut.IssueTx(ctx, signedExport), "%T.IssueTx()", sut.Client) + + blk := sut.buildVerify(ctx, t, sut.lastAccepted(ctx, t)) + 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 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() + + // 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/BUILD.bazel b/vms/saevm/sae/BUILD.bazel index 2b79af49b4ed..01e2e67eb609 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//trie", 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/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{}, diff --git a/vms/saevm/sae/vm_test.go b/vms/saevm/sae/vm_test.go index 7dddf9de9787..f24dd81ff6ce 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"), + } +}