diff --git a/vms/saevm/cchain/tx/BUILD.bazel b/vms/saevm/cchain/tx/BUILD.bazel index e73e171da14d..6394885a8644 100644 --- a/vms/saevm/cchain/tx/BUILD.bazel +++ b/vms/saevm/cchain/tx/BUILD.bazel @@ -19,6 +19,7 @@ go_library( "//chains/atomic", "//codec", "//codec/linearcodec", + "//graft/coreth/core/extstate", "//graft/coreth/plugin/evm/atomic", "//graft/coreth/plugin/evm/upgrade/ap5", "//ids", @@ -44,6 +45,8 @@ go_test( embed = [":tx"], deps = [ "//chains/atomic", + "//graft/coreth/core/extstate", + "//graft/coreth/plugin/evm", "//graft/coreth/plugin/evm/atomic", "//graft/coreth/plugin/evm/atomic/vm", "//ids", @@ -57,6 +60,9 @@ go_test( "//vms/saevm/hook", "//vms/secp256k1fx", "@com_github_ava_labs_libevm//common", + "@com_github_ava_labs_libevm//core/rawdb", + "@com_github_ava_labs_libevm//core/state", + "@com_github_ava_labs_libevm//core/types", "@com_github_google_go_cmp//cmp", "@com_github_google_go_cmp//cmp/cmpopts", "@com_github_holiman_uint256//:uint256", diff --git a/vms/saevm/cchain/tx/compatibility_test.go b/vms/saevm/cchain/tx/compatibility_test.go index a62d254e74c3..72ed00dc6105 100644 --- a/vms/saevm/cchain/tx/compatibility_test.go +++ b/vms/saevm/cchain/tx/compatibility_test.go @@ -5,6 +5,7 @@ package tx_test import ( "encoding/json" + "math" "math/big" "testing" @@ -15,7 +16,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ava-labs/avalanchego/graft/coreth/core/extstate" "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/atomic" + "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/snow" "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx/txtest" @@ -24,10 +27,17 @@ import ( . "github.com/ava-labs/avalanchego/vms/saevm/cchain/tx" ) -// fuzz seeds f with [NewTxs] and fuzzes the test. +// fuzz seeds f with [NewTxs], specifies simple alphabets used to bias the +// fuzzer, and fuzzes the test. func fuzz(f *testing.F, ff func(t *testing.T, tx *Tx)) { fuzzer := &txtest.F{ F: f, + Addresses: []common.Address{ + {1}, + }, + AssetIDs: []ids.ID{ + AVAXAssetID, + }, } for _, tx := range NewTxs { fuzzer.Add(tx) @@ -128,8 +138,7 @@ func (s *asOpStateDB) SubBalance(addr common.Address, amount *uint256.Int) { } func (*asOpStateDB) GetBalance(common.Address) *uint256.Int { - // Large enough to never underflow, but small enough to never overflow. - return new(uint256.Int).Lsh(uint256.NewInt(1), 128) + return largeUint256() } func (*asOpStateDB) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) {} @@ -137,8 +146,7 @@ func (*asOpStateDB) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) { func (*asOpStateDB) SubBalanceMultiCoin(common.Address, common.Hash, *big.Int) {} func (*asOpStateDB) GetBalanceMultiCoin(common.Address, common.Hash) *big.Int { - // Large enough to never underflow, but small enough to never overflow. - return new(big.Int).Lsh(big.NewInt(1), 128) + return largeBigInt() } func (s *asOpStateDB) SetNonce(addr common.Address, nonce uint64) { @@ -165,3 +173,50 @@ func FuzzAtomicRequestsCompatibility(f *testing.F) { assert.Equal(t, wantRequests, gotRequests, "requests") }) } + +func FuzzTransferNonAVAXCompatibility(f *testing.F) { + fuzz(f, func(t *testing.T, newTx *Tx) { + op, err := newTx.AsOp(AVAXAssetID) + if err != nil { + t.Skip("invalid tx") + } + + oldState := NewEmptyStateDB(t) + newState := NewEmptyStateDB(t) + states := []*extstate.StateDB{oldState, newState} + + if tx, ok := newTx.Unsigned.(*Export); ok { + for _, in := range tx.Ins { + // Coreth silently overflows the nonce, whereas SAE will leave + // the nonce unmodified. This difference doesn't matter on live + // networks. + if in.Nonce == math.MaxUint64 { + t.Skip("nonce overflow") + } + + for _, state := range states { + state.AddBalance(in.Address, largeUint256()) + state.SetNonce(in.Address, in.Nonce) + state.AddBalanceMultiCoin(in.Address, common.Hash(in.AssetID), largeBigInt()) + } + } + } + + var ( + oldTx = ToOldTx(t, newTx) + ctx = &snow.Context{AVAXAssetID: AVAXAssetID} + ) + require.NoErrorf(t, oldTx.EVMStateTransfer(ctx, oldState), "%T.EVMStateTransfer()", oldTx) + require.NoErrorf(t, newTx.TransferNonAVAX(AVAXAssetID, newState), "%T.TransferNonAVAX()", newTx) + require.NoErrorf(t, op.ApplyTo(newState.StateDB), "%T.ApplyTo(%T)", op, newState.StateDB) + + if diff := CompareStateDBs(oldState, newState); diff != "" { + t.Errorf("%T.TransferNonAVAX() diff (-want +got):\n%s", newTx, diff) + } + }) +} + +// largeUint256 and largeBigInt return a balance large enough to never underflow +// but small enough to never overflow during test arithmetic. +func largeUint256() *uint256.Int { return new(uint256.Int).Lsh(uint256.NewInt(1), 128) } +func largeBigInt() *big.Int { return new(big.Int).Lsh(big.NewInt(1), 128) } diff --git a/vms/saevm/cchain/tx/export.go b/vms/saevm/cchain/tx/export.go index 236ed6e4d957..8a06072682ec 100644 --- a/vms/saevm/cchain/tx/export.go +++ b/vms/saevm/cchain/tx/export.go @@ -6,12 +6,14 @@ package tx import ( "errors" "fmt" + "math/big" "github.com/ava-labs/libevm/common" // Imported for [atomic.UnsignedExportTx.Burned] comment resolution. _ "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/atomic" + "github.com/ava-labs/avalanchego/graft/coreth/core/extstate" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/vms/components/avax" @@ -136,3 +138,22 @@ func (e *Export) atomicRequests(txID ids.ID) (ids.ID, *chainsatomic.Requests, er } return e.DestinationChain, &chainsatomic.Requests{PutRequests: elems}, nil } + +var errInsufficientFunds = errors.New("insufficient funds") + +// TransferNonAVAX subtracts the non-AVAX balances from the statedb. +func (e *Export) TransferNonAVAX(avaxAssetID ids.ID, statedb *extstate.StateDB) error { + for _, in := range e.Ins { + if in.AssetID == avaxAssetID { + continue + } + + coinID := common.Hash(in.AssetID) + amount := new(big.Int).SetUint64(in.Amount) + if balance := statedb.GetBalanceMultiCoin(in.Address, coinID); balance.Cmp(amount) < 0 { + return fmt.Errorf("%w: address %s asset %s has %d want %d", errInsufficientFunds, in.Address, coinID, balance, amount) + } + statedb.SubBalanceMultiCoin(in.Address, coinID, amount) + } + return nil +} diff --git a/vms/saevm/cchain/tx/import.go b/vms/saevm/cchain/tx/import.go index ce35ffd8281a..ff1a0310465a 100644 --- a/vms/saevm/cchain/tx/import.go +++ b/vms/saevm/cchain/tx/import.go @@ -6,6 +6,7 @@ package tx import ( "errors" "fmt" + "math/big" "github.com/ava-labs/libevm/common" "github.com/holiman/uint256" @@ -13,6 +14,7 @@ import ( // Imported for [atomic.UnsignedImportTx.Burned] comment resolution. _ "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/atomic" + "github.com/ava-labs/avalanchego/graft/coreth/core/extstate" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/vms/components/avax" @@ -121,3 +123,17 @@ func (i *Import) atomicRequests(ids.ID) (ids.ID, *chainsatomic.Requests, error) } return i.SourceChain, &chainsatomic.Requests{RemoveRequests: utxoIDs}, nil } + +// TransferNonAVAX adds the non-AVAX balances to the statedb. +func (i *Import) TransferNonAVAX(avaxAssetID ids.ID, statedb *extstate.StateDB) error { + for _, out := range i.Outs { + if out.AssetID == avaxAssetID { + continue + } + + coinID := common.Hash(out.AssetID) + amount := new(big.Int).SetUint64(out.Amount) + statedb.AddBalanceMultiCoin(out.Address, coinID, amount) + } + return nil +} diff --git a/vms/saevm/cchain/tx/tx.go b/vms/saevm/cchain/tx/tx.go index 95b47a1bc427..ec228ddbb837 100644 --- a/vms/saevm/cchain/tx/tx.go +++ b/vms/saevm/cchain/tx/tx.go @@ -15,6 +15,7 @@ import ( // Imported for [atomic.TxBytesGas] comment resolution. _ "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/atomic" + "github.com/ava-labs/avalanchego/graft/coreth/core/extstate" "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/upgrade/ap5" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/hashing" @@ -36,9 +37,15 @@ type Tx struct { // Unsigned is a common interface implemented by [Import] and [Export]. // -// TODO(StephenButtolph): Expand this interface to include UTXO handling, -// verification, and state execution. +// TODO(StephenButtolph): Expand this interface to include UTXO handling and +// verification. type Unsigned interface { + // TransferNonAVAX transfers the non-AVAX balances requested by this + // transaction. + // + // Non-AVAX transfers were only allowed prior to the Banff upgrade. + TransferNonAVAX(avaxAssetID ids.ID, statedb *extstate.StateDB) error + // burned returns the amount of assetID that is consumed but not produced by // this transaction. burned(assetID ids.ID) (uint64, error) @@ -47,8 +54,8 @@ type Unsigned interface { // transaction. numSigs() (uint64, error) - // asOp returns the operation that this transaction performs on the EVM - // state. + // asOp returns the operation that this transaction performs on the + // EVM-native state. Ops do not include any non-AVAX balance changes. asOp(avaxAssetID ids.ID) (op, error) // atomicRequests returns the operations that should be applied to shared diff --git a/vms/saevm/cchain/tx/tx_test.go b/vms/saevm/cchain/tx/tx_test.go index 9999ce3fc8d7..411af6ff298e 100644 --- a/vms/saevm/cchain/tx/tx_test.go +++ b/vms/saevm/cchain/tx/tx_test.go @@ -7,9 +7,14 @@ import ( "encoding/json" "errors" "math" + "math/big" + "os" "testing" "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/holiman/uint256" @@ -19,6 +24,8 @@ import ( // Imported for [vm.VerifierBackend] comment resolution. _ "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/atomic/vm" + "github.com/ava-labs/avalanchego/graft/coreth/core/extstate" + "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm" "github.com/ava-labs/avalanchego/graft/coreth/plugin/evm/atomic" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/vms/components/avax" @@ -31,6 +38,11 @@ import ( safemath "github.com/ava-labs/avalanchego/utils/math" ) +func TestMain(m *testing.M) { + evm.RegisterAllLibEVMExtras() + os.Exit(m.Run()) +} + // Tests is defined at the package level to allow sharing between fuzz tests and // unit tests. var ( @@ -1310,3 +1322,221 @@ func TestAtomicRequests(t *testing.T) { }) } } + +func NewEmptyStateDB(t testing.TB) *extstate.StateDB { + t.Helper() + + db := state.NewDatabase(rawdb.NewMemoryDatabase()) + sdb, err := state.New(types.EmptyRootHash, db, nil) + require.NoError(t, err) + return extstate.New(sdb) +} + +func CompareStateDBs(want, got *extstate.StateDB) string { + // Finalize the trie structures so that the state DB comparison includes + // any changes. + for _, v := range []*extstate.StateDB{want, got} { + v.Finalise(true) + v.IntermediateRoot(true) + } + + opts := []cmp.Option{ + cmpopts.IgnoreUnexported(extstate.StateDB{}), + cmputils.StateDBs(), + } + return cmp.Diff(want, got, opts...) +} + +func TestTransferNonAVAX(t *testing.T) { + var ( + alice = common.Address{1} + bob = common.Address{2} + btc = ids.ID{3} + eth = ids.ID{4} + ) + tests := []struct { + name string + init map[common.Address]map[ids.ID]uint64 + tx Unsigned + want map[common.Address]map[ids.ID]uint64 + wantErr error + }{ + { + name: "import_avax", + tx: &Import{ + Outs: []Output{ + {Address: alice, Amount: 1, AssetID: AVAXAssetID}, + }, + }, + }, + { + name: "import_non_avax", + tx: &Import{ + Outs: []Output{ + {Address: alice, Amount: 1, AssetID: btc}, + }, + }, + want: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 1, + }, + }, + }, + { + name: "import_non_avax_adds", + init: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 1, + }, + }, + tx: &Import{ + Outs: []Output{ + {Address: alice, Amount: 1, AssetID: btc}, + }, + }, + want: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 2, + }, + }, + }, + { + name: "import_many", + tx: &Import{ + Outs: []Output{ + {Address: alice, Amount: 1, AssetID: AVAXAssetID}, + {Address: alice, Amount: 10, AssetID: AVAXAssetID}, + {Address: bob, Amount: 100, AssetID: AVAXAssetID}, + {Address: alice, Amount: 1_000, AssetID: btc}, + {Address: alice, Amount: 10_000, AssetID: btc}, + {Address: bob, Amount: 100_000, AssetID: btc}, + {Address: bob, Amount: 1_000_000, AssetID: eth}, + }, + }, + want: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 11_000, + }, + bob: { + btc: 100_000, + eth: 1_000_000, + }, + }, + }, + { + name: "export_avax", + tx: &Export{ + Ins: []Input{ + {Address: alice, Amount: 1, AssetID: AVAXAssetID}, + }, + }, + }, + { + name: "export_non_avax", + init: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 2, + }, + }, + tx: &Export{ + Ins: []Input{ + {Address: alice, Amount: 1, AssetID: btc}, + }, + }, + want: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 1, + }, + }, + }, + { + name: "export_many", + init: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 22_000, + }, + bob: { + btc: 200_000, + eth: 2_000_000, + }, + }, + tx: &Export{ + Ins: []Input{ + {Address: alice, Amount: 1, AssetID: AVAXAssetID}, + {Address: alice, Amount: 10, AssetID: AVAXAssetID}, + {Address: bob, Amount: 100, AssetID: AVAXAssetID}, + {Address: alice, Amount: 1_000, AssetID: btc}, + {Address: alice, Amount: 10_000, AssetID: btc}, + {Address: bob, Amount: 100_000, AssetID: btc}, + {Address: bob, Amount: 1_000_000, AssetID: eth}, + }, + }, + want: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 11_000, + }, + bob: { + btc: 100_000, + eth: 1_000_000, + }, + }, + }, + { + name: "export_non_avax_insufficient", + tx: &Export{ + Ins: []Input{ + {Address: alice, Amount: 1, AssetID: btc}, + }, + }, + wantErr: errInsufficientFunds, + }, + { + name: "export_non_avax_total_insufficient", + init: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 2, + }, + }, + tx: &Export{ + Ins: []Input{ + {Address: alice, Amount: 1, AssetID: btc}, + {Address: alice, Amount: 2, AssetID: btc}, + }, + }, + want: map[common.Address]map[ids.ID]uint64{ + alice: { + btc: 1, + }, + }, + wantErr: errInsufficientFunds, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var ( + want = NewEmptyStateDB(t) + toBig = func(v uint64) *big.Int { return new(big.Int).SetUint64(v) } + ) + for addr, balances := range test.want { + for assetID, amount := range balances { + coinID := common.Hash(assetID) + want.AddBalanceMultiCoin(addr, coinID, toBig(amount)) + } + } + + got := NewEmptyStateDB(t) + for addr, balances := range test.init { + for assetID, amount := range balances { + coinID := common.Hash(assetID) + got.AddBalanceMultiCoin(addr, coinID, toBig(amount)) + } + } + + err := test.tx.TransferNonAVAX(AVAXAssetID, got) + require.ErrorIs(t, err, test.wantErr) + if diff := CompareStateDBs(want, got); diff != "" { + t.Errorf("%T.TransferNonAVAX() diff (-want +got):\n%s", test.tx, diff) + } + }) + } +}