diff --git a/engine/common/version/version_control.go b/engine/common/version/version_control.go index bc57584d38d..f6529a4e395 100644 --- a/engine/common/version/version_control.go +++ b/engine/common/version/version_control.go @@ -57,6 +57,8 @@ var defaultCompatibilityOverrides = map[string]struct{}{ "0.44.0": {}, // mainnet, testnet "0.44.1": {}, // mainnet, testnet "0.44.7": {}, // mainnet, testnet + "0.44.10": {}, // mainnet, testnet + "0.44.14": {}, // mainnet, testnet } // VersionControl manages the version control system for the node. diff --git a/engine/execution/state/bootstrap/bootstrap_test.go b/engine/execution/state/bootstrap/bootstrap_test.go index 1f4ed3f1ffe..44f3c2d0acb 100644 --- a/engine/execution/state/bootstrap/bootstrap_test.go +++ b/engine/execution/state/bootstrap/bootstrap_test.go @@ -57,7 +57,7 @@ func TestBootstrapLedger(t *testing.T) { } func TestBootstrapLedger_ZeroTokenSupply(t *testing.T) { - expectedStateCommitmentBytes, _ := hex.DecodeString("8d73f47bfde633129a974c8e7ddf920d58ba1e5b7b0f2ca72e0cce85b3991b83") + expectedStateCommitmentBytes, _ := hex.DecodeString("33c80faa1cf40865168552e63744538dcc754234ab1a7b31ffb2e2272baaf524") expectedStateCommitment, err := flow.ToStateCommitment(expectedStateCommitmentBytes) require.NoError(t, err) @@ -104,7 +104,7 @@ func TestBootstrapLedger_ZeroTokenSupply(t *testing.T) { // - transaction fee deduction // This tests that the state commitment has not changed for the bookkeeping parts of the transaction. func TestBootstrapLedger_EmptyTransaction(t *testing.T) { - expectedStateCommitmentBytes, _ := hex.DecodeString("21a8e0c4542dac3655a5aa3587a99257546efa23ea288fed07aa07e0211302b6") + expectedStateCommitmentBytes, _ := hex.DecodeString("5c3dcf747d788225731e6df6bb5e32550a2a59eaf9b6e1458f4ee850778e42f2") expectedStateCommitment, err := flow.ToStateCommitment(expectedStateCommitmentBytes) require.NoError(t, err) diff --git a/fvm/evm/emulator/emulator.go b/fvm/evm/emulator/emulator.go index 8cc0da9a7f2..464fe10b2dc 100644 --- a/fvm/evm/emulator/emulator.go +++ b/fvm/evm/emulator/emulator.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math/big" + "slices" gethCommon "github.com/ethereum/go-ethereum/common" gethCore "github.com/ethereum/go-ethereum/core" @@ -21,6 +22,21 @@ import ( "github.com/onflow/flow-go/model/flow" ) +// List of EOAs with restricted access to EVM, due to malicious activity. +var restrictedEOAs = []gethCommon.Address{ + gethCommon.HexToAddress("0x2e7C4b71397f10c93dC0C2ba6f8f179A47F994e1"), + gethCommon.HexToAddress("0x9D9247F5C3F3B78F7EE2C480B9CDaB91393Bf4D6"), +} + +var restrictedEOAError = errors.New( + "this account has been restricted by the Community Governance Council in connection to a protocol exploit, please reach out to security@flowfoundation.com for inquiries or information related to the attack", +) + +// isRestrictedEOA checks if the given address is in the restricted EOAs list +func isRestrictedEOA(addr gethCommon.Address) bool { + return slices.Contains(restrictedEOAs, addr) +} + // Emulator wraps an EVM runtime where evm transactions // and direct calls are accepted. type Emulator struct { @@ -189,7 +205,12 @@ func (bl *BlockView) RunTransaction( if err != nil { // this is not a fatal error (e.g. due to bad signature) // not a valid transaction - return types.NewInvalidResult(tx, err), nil + return types.NewInvalidResult(tx.Type(), tx.Hash(), err), nil + } + + // Restrict access to EVM, for EOAs with proven malicious activity + if isRestrictedEOA(msg.From) { + return types.NewInvalidResult(tx.Type(), tx.Hash(), restrictedEOAError), nil } // call tracer @@ -244,7 +265,13 @@ func (bl *BlockView) BatchRunTransactions(txs []*gethTypes.Transaction) ([]*type GetSigner(bl.config), proc.config.BlockContext.BaseFee) if err != nil { - batchResults[i] = types.NewInvalidResult(tx, err) + batchResults[i] = types.NewInvalidResult(tx.Type(), tx.Hash(), err) + continue + } + + // Restrict access to EVM, for EOAs with proven malicious activity + if isRestrictedEOA(msg.From) { + batchResults[i] = types.NewInvalidResult(tx.Type(), tx.Hash(), restrictedEOAError) continue } @@ -385,7 +412,8 @@ func (proc *procedure) mintTo( value, isValid := checkAndConvertValue(call.Value) if !isValid { return types.NewInvalidResult( - call.Transaction(), + call.Type, + call.Hash(), types.ErrInvalidBalance, ), nil } @@ -430,7 +458,8 @@ func (proc *procedure) withdrawFrom( value, isValid := checkAndConvertValue(call.Value) if !isValid { return types.NewInvalidResult( - call.Transaction(), + call.Type, + call.Hash(), types.ErrInvalidBalance, ), nil } @@ -438,7 +467,8 @@ func (proc *procedure) withdrawFrom( // check balance is not prone to rounding error if !types.AttoFlowBalanceIsValidForFlowVault(call.Value) { return types.NewInvalidResult( - call.Transaction(), + call.Type, + call.Hash(), types.ErrWithdrawBalanceRounding, ), nil } @@ -486,7 +516,8 @@ func (proc *procedure) deployAt( castedValue, isValid := checkAndConvertValue(call.Value) if !isValid { return types.NewInvalidResult( - call.Transaction(), + call.Type, + call.Hash(), types.ErrInvalidBalance, ), nil } diff --git a/fvm/evm/emulator/restricted_eoa_test.go b/fvm/evm/emulator/restricted_eoa_test.go new file mode 100644 index 00000000000..a93d8440275 --- /dev/null +++ b/fvm/evm/emulator/restricted_eoa_test.go @@ -0,0 +1,82 @@ +package emulator + +import ( + "testing" + + gethCommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsRestrictedEOA(t *testing.T) { + t.Run("restricted address 1 should return true", func(t *testing.T) { + addr := gethCommon.HexToAddress("0x2e7C4b71397f10c93dC0C2ba6f8f179A47F994e1") + result := isRestrictedEOA(addr) + assert.True(t, result, "first restricted address should be detected as restricted") + }) + + t.Run("restricted address 2 should return true", func(t *testing.T) { + addr := gethCommon.HexToAddress("0x9D9247F5C3F3B78F7EE2C480B9CDaB91393Bf4D6") + result := isRestrictedEOA(addr) + assert.True(t, result, "second restricted address should be detected as restricted") + }) + + t.Run("non-restricted address should return false", func(t *testing.T) { + addr := gethCommon.HexToAddress("0x1234567890123456789012345678901234567890") + result := isRestrictedEOA(addr) + assert.False(t, result, "non-restricted address should return false") + }) + + t.Run("empty address should return false", func(t *testing.T) { + addr := gethCommon.Address{} + result := isRestrictedEOA(addr) + assert.False(t, result, "empty address should return false") + }) + + t.Run("case sensitivity - same address with different case should match", func(t *testing.T) { + // Ethereum addresses are case-insensitive in hex representation + // but gethCommon.HexToAddress normalizes to lowercase + addr1 := gethCommon.HexToAddress("0x2e7C4b71397f10c93dC0C2ba6f8f179A47F994e1") + addr2 := gethCommon.HexToAddress("0x2e7c4b71397f10c93dc0c2ba6f8f179a47f994e1") + require.Equal(t, addr1, addr2, "addresses should be equal regardless of case") + + result1 := isRestrictedEOA(addr1) + result2 := isRestrictedEOA(addr2) + assert.Equal(t, result1, result2, "results should be the same for same address") + assert.True(t, result1, "both should be detected as restricted") + }) + + t.Run("all addresses in restrictedEOAs list should be detected", func(t *testing.T) { + for _, addr := range restrictedEOAs { + result := isRestrictedEOA(addr) + assert.True(t, result, "address %s should be detected as restricted", addr.Hex()) + } + }) + + t.Run("address not in list should return false", func(t *testing.T) { + // Test with addresses that are similar but not in the list + testAddresses := []gethCommon.Address{ + gethCommon.HexToAddress("0x2e7C4b71397f10c93dC0C2ba6f8f179A47F994e0"), // one byte different + gethCommon.HexToAddress("0x9D9247F5C3F3B78F7EE2C480B9CDaB91393Bf4D7"), // one byte different + gethCommon.HexToAddress("0x0000000000000000000000000000000000000001"), + gethCommon.HexToAddress("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"), + } + + for _, addr := range testAddresses { + // Skip if the address is actually in the restricted list + isInList := false + for _, restrictedAddr := range restrictedEOAs { + if addr == restrictedAddr { + isInList = true + break + } + } + if isInList { + continue + } + + result := isRestrictedEOA(addr) + assert.False(t, result, "address %s should not be detected as restricted", addr.Hex()) + } + }) +} diff --git a/fvm/evm/stdlib/contract.cdc b/fvm/evm/stdlib/contract.cdc index 1d0b383cbab..d29cc1afc0f 100644 --- a/fvm/evm/stdlib/contract.cdc +++ b/fvm/evm/stdlib/contract.cdc @@ -690,8 +690,8 @@ access(all) contract EVM { access(all) fun run(tx: [UInt8], coinbase: EVMAddress): Result { return InternalEVM.run( - tx: tx, - coinbase: coinbase.bytes + tx: tx, + coinbase: coinbase.bytes ) as! Result } @@ -1028,6 +1028,22 @@ access(all) contract EVM { self.account.storage.save(<-create Heartbeat(), to: /storage/EVMHeartbeat) } + /// This is only a temporary measure and will be removed immediately + /// after the remediation of the illicit tokens + // in the Dec 2025 security incident is complete. + /// This function can only be called from the `FlowServiceAccount` contract, + /// and only from the holder of `FlowServiceAccount.Administrator` resource. + access(account) + fun reclaimFundsFromAttackerEOAs(from: String, to: String, amount: UInt): Result { + return InternalEVM.call( + from: EVM.addressFromString(from).bytes, + to: EVM.addressFromString(to).bytes, + data: [], + gasLimit: 1_000_000, + value: amount + ) as! Result + } + init() { self.setupHeartbeat() } diff --git a/fvm/evm/types/result.go b/fvm/evm/types/result.go index 19e54311957..a585eb11655 100644 --- a/fvm/evm/types/result.go +++ b/fvm/evm/types/result.go @@ -54,10 +54,10 @@ type ResultSummary struct { // NewInvalidResult creates a new result that hold transaction validation // error as well as the defined gas cost for validation. -func NewInvalidResult(tx *gethTypes.Transaction, err error) *Result { +func NewInvalidResult(txType uint8, txHash gethCommon.Hash, err error) *Result { return &Result{ - TxType: tx.Type(), - TxHash: tx.Hash(), + TxType: txType, + TxHash: txHash, ValidationError: err, GasConsumed: InvalidTransactionGasCost, } diff --git a/fvm/script.go b/fvm/script.go index e79b8c5ff0f..1c8289d60ac 100644 --- a/fvm/script.go +++ b/fvm/script.go @@ -203,6 +203,11 @@ func (executor *scriptExecutor) executeScript() error { chainID := executor.ctx.Chain.ChainID() if executor.ctx.EVMEnabled { + // Setup InternalEVM in both environments because: + // - Scripts execute in ScriptRuntimeEnv + // - But system contract invocations (e.g., getAccount().balance) use TxRuntimeEnv + + // Setup InternalEVM in ScriptRuntimeEnv (for script execution) err := evm.SetupEnvironment( chainID, executor.env, @@ -211,6 +216,21 @@ func (executor *scriptExecutor) executeScript() error { if err != nil { return err } + + // Setup InternalEVM in TxRuntimeEnv (for system contract invocations) + // This solves the problem where FlowServiceAccount (which imports EVM) needs + // InternalEVM to be available during dependency checking when invoked via + // system contracts (e.g., getAccount().balance). Without this, type checking + // fails with "cannot find variable in this scope: `InternalEVM`" because + // system contract invocations use TxRuntimeEnv, not ScriptRuntimeEnv. + err = evm.SetupEnvironment( + chainID, + executor.env, + rt.TxRuntimeEnv, + ) + if err != nil { + return err + } } value, err := rt.ExecuteScript( diff --git a/utils/unittest/execution_state.go b/utils/unittest/execution_state.go index 82ff78a67c9..4b95c463a11 100644 --- a/utils/unittest/execution_state.go +++ b/utils/unittest/execution_state.go @@ -23,7 +23,7 @@ const ServiceAccountPrivateKeySignAlgo = crypto.ECDSAP256 const ServiceAccountPrivateKeyHashAlgo = hash.SHA2_256 // Pre-calculated state commitment with root account with the above private key -const GenesisStateCommitmentHex = "035698fda15a78e87fc7920f4b8084606b689012864f90b26bb65fefa4adf9fe" +const GenesisStateCommitmentHex = "976de8ab609fcf1d7d9d9536527593aa28a0dd1e18eb58158ca3e1644e730329" var GenesisStateCommitment flow.StateCommitment @@ -87,10 +87,10 @@ func genesisCommitHexByChainID(chainID flow.ChainID) string { return GenesisStateCommitmentHex } if chainID == flow.Testnet { - return "0a8012fdf9a6bfc31d8949ce76340f3059903fd508deac0638fa30dbd206e252" + return "8b0f83512ccbca71ca85447f788506181eadb83744c0f49bd0f3bd389ac555f8" } if chainID == flow.Sandboxnet { return "e1c08b17f9e5896f03fe28dd37ca396c19b26628161506924fbf785834646ea1" } - return "f4595c2d492bd930b2b37c3bf731d758a4a32874e41c1e0388d1091a633dd3d2" + return "a01cb6d7b23beb2cc8c03a31f2c536ab3aa1382d5f484c4276055e59023bfc8c" }