diff --git a/app/app.go b/app/app.go index 59d28cd..ed6f364 100644 --- a/app/app.go +++ b/app/app.go @@ -110,6 +110,7 @@ import ( ibckeeper "github.com/cosmos/ibc-go/v8/modules/core/keeper" solomachine "github.com/cosmos/ibc-go/v8/modules/light-clients/06-solomachine" ibctm "github.com/cosmos/ibc-go/v8/modules/light-clients/07-tendermint" + ethparams "github.com/ethereum/go-ethereum/params" srvflags "github.com/evmos/ethermint/server/flags" ethermint "github.com/evmos/ethermint/types" "github.com/evmos/ethermint/x/evm" @@ -134,8 +135,10 @@ import ( "github.com/Galactica-corp/galactica/docs" runtimeservices "github.com/cosmos/cosmos-sdk/runtime/services" + stakingprecompile "github.com/Galactica-corp/galactica/precompiles/staking" capabilitytypes "github.com/cosmos/ibc-go/modules/capability/types" icatypes "github.com/cosmos/ibc-go/v8/modules/apps/27-interchain-accounts/types" + "github.com/ethereum/go-ethereum/core/vm" ) const ( @@ -463,6 +466,11 @@ func New( ) app.FeeMarketKeeper = &feeMarketKeeper + stakingPrecompile, err := stakingprecompile.NewPrecompile(*app.StakingKeeper, app.AuthzKeeper) + if err != nil { + panic(fmt.Errorf("failed to load staking precompile: %w", err)) + } + // Set authority to x/gov module account to only expect the module account to update params evmS := app.GetSubspace(evmtypes.ModuleName) app.EvmKeeper = evmkeeper.NewKeeper( @@ -472,7 +480,11 @@ func New( app.AccountKeeper, app.BankKeeper, app.StakingKeeper, app.FeeMarketKeeper, tracer, evmS, - nil, + []evmkeeper.CustomContractFn{ + func(_ sdk.Context, rules ethparams.Rules) vm.PrecompiledContract { + return stakingPrecompile + }, + }, ) app.CapabilityKeeper = capabilitykeeper.NewKeeper(app.appCodec, capKVStoreKey, capKVMemKey) diff --git a/precompiles/staking/abi.json b/precompiles/staking/abi.json new file mode 100644 index 0000000..4c57715 --- /dev/null +++ b/precompiles/staking/abi.json @@ -0,0 +1,60 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "delegatorAddress", + "type": "address" + }, + { + "internalType": "string", + "name": "validatorAddress", + "type": "string" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "delegate", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "delegatorAddress", + "type": "address" + }, + { + "internalType": "string", + "name": "validatorAddress", + "type": "string" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "undelegate", + "outputs": [ + { + "internalType": "int64", + "name": "completionTime", + "type": "int64" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/precompiles/staking/staking.go b/precompiles/staking/staking.go new file mode 100644 index 0000000..463e000 --- /dev/null +++ b/precompiles/staking/staking.go @@ -0,0 +1,186 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package staking + +import ( + "bytes" + "embed" + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "cosmossdk.io/log" + storetypes "cosmossdk.io/store/types" + authzkeeper "github.com/cosmos/cosmos-sdk/x/authz/keeper" + stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/evmos/ethermint/x/evm/statedb" +) + +var ( + stakingContractAddress = common.BytesToAddress([]byte{100}) +) + +type ( + ErrorOutOfGas = storetypes.ErrorOutOfGas + ErrorGasOverflow = storetypes.ErrorGasOverflow +) + +const ( + ErrDecreaseAmountTooBig = "amount by which the allowance should be decreased is greater than the authorization limit: %s > %s" + ErrDifferentOriginFromDelegator = "origin address %s is not the same as delegator address %s" + ErrNoDelegationFound = "delegation with delegator %s not found for validator %s" +) + +var _ vm.PrecompiledContract = &Precompile{} + +//go:embed abi.json +var f embed.FS + +type Precompile struct { + abi abi.ABI + AuthzKeeper authzkeeper.Keeper + stakingKeeper stakingkeeper.Keeper + addressContract common.Address + kvGasConfig storetypes.GasConfig + transientKVGasConfig storetypes.GasConfig +} + +// TODO +func (p Precompile) RequiredGas(input []byte) uint64 { + return 0 +} + +func NewPrecompile( + stakingKeeper stakingkeeper.Keeper, + authzKeeper authzkeeper.Keeper, +) (*Precompile, error) { + abiBz, err := f.ReadFile("abi.json") + if err != nil { + return nil, fmt.Errorf("error loading the staking ABI %s", err) + } + + newAbi, err := abi.JSON(bytes.NewReader(abiBz)) + if err != nil { + return nil, err + } + + return &Precompile{ + stakingKeeper: stakingKeeper, + AuthzKeeper: authzKeeper, + abi: newAbi, + addressContract: stakingContractAddress, + kvGasConfig: storetypes.KVGasConfig(), + transientKVGasConfig: storetypes.TransientGasConfig(), + }, nil +} + +func (Precompile) Address() common.Address { + return stakingContractAddress +} + +func (p Precompile) Run(evm *vm.EVM, contract *vm.Contract, readOnly bool) (bz []byte, err error) { + ctx, stateDB, method, initialGas, args, err := p.RunSetup(evm, contract, readOnly, p.IsTransaction) + if err != nil { + return nil, err + } + + if err := stateDB.Commit(); err != nil { + return nil, err + } + + switch method.Name { + case DelegateMethod: + bz, err = p.Delegate(ctx, evm.Origin, contract, stateDB, method, args) + case UndelegateMethod: + bz, err = p.Undelegate(ctx, evm.Origin, contract, stateDB, method, args) + } + + if err != nil { + return nil, err + } + + cost := ctx.GasMeter().GasConsumed() - initialGas + + if !contract.UseGas(cost) { + return nil, vm.ErrOutOfGas + } + + return bz, nil +} + +func (Precompile) IsTransaction(method string) bool { + switch method { + case DelegateMethod, + UndelegateMethod: + return true + default: + return false + } +} + +func (p Precompile) Logger(ctx sdk.Context) log.Logger { + return ctx.Logger().With("evm extension", fmt.Sprintf("x/%s", "staking")) +} + +func (p Precompile) RunSetup( + evm *vm.EVM, + contract *vm.Contract, + readOnly bool, + isTransaction func(name string) bool, +) (ctx sdk.Context, stateDB *statedb.StateDB, method *abi.Method, gasConfig storetypes.Gas, args []interface{}, err error) { + stateDB, ok := evm.StateDB.(*statedb.StateDB) + if !ok { + return sdk.Context{}, nil, nil, uint64(0), nil, fmt.Errorf(ErrNotRunInEvm) + } + ctx = stateDB.Context() + + methodID := contract.Input[:4] + method, err = p.abi.MethodById(methodID) + if err != nil { + return sdk.Context{}, nil, nil, uint64(0), nil, err + } + + // return error if trying to write to state during a read-only call + if readOnly && isTransaction(method.Name) { + return sdk.Context{}, nil, nil, uint64(0), nil, vm.ErrWriteProtection + } + + argsBz := contract.Input[4:] + args, err = method.Inputs.Unpack(argsBz) + if err != nil { + return sdk.Context{}, nil, nil, uint64(0), nil, err + } + + initialGas := ctx.GasMeter().GasConsumed() + + defer HandleGasError(ctx, contract, initialGas, &err)() + + ctx = ctx.WithGasMeter(storetypes.NewGasMeter(contract.Gas)).WithKVGasConfig(p.kvGasConfig). + WithTransientKVGasConfig(p.transientKVGasConfig) + + ctx.GasMeter().ConsumeGas(initialGas, "creating a new gas meter") + + return ctx, stateDB, method, initialGas, args, nil +} + +func HandleGasError(ctx sdk.Context, contract *vm.Contract, initialGas storetypes.Gas, err *error) func() { + return func() { + if r := recover(); r != nil { + switch r.(type) { + case ErrorOutOfGas: + usedGas := ctx.GasMeter().GasConsumed() - initialGas + _ = contract.UseGas(usedGas) + + *err = vm.ErrOutOfGas + ctx = ctx.WithKVGasConfig(storetypes.GasConfig{}). + WithTransientKVGasConfig(storetypes.GasConfig{}) + default: + panic(r) + } + } + } +} diff --git a/precompiles/staking/tx.go b/precompiles/staking/tx.go new file mode 100644 index 0000000..404653a --- /dev/null +++ b/precompiles/staking/tx.go @@ -0,0 +1,234 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package staking + +import ( + "fmt" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/authz" + authzkeeper "github.com/cosmos/cosmos-sdk/x/authz/keeper" + stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/evmos/ethermint/x/evm/statedb" +) + +const ( + // ErrAuthzDoesNotExistOrExpired is raised when the authorization does not exist. + ErrAuthzDoesNotExistOrExpired = "authorization to %s for address %s does not exist or is expired" + // ErrEmptyMethods is raised when the given methods array is empty. + ErrEmptyMethods = "no methods defined; expected at least one message type url" + // ErrEmptyStringInMethods is raised when the given methods array contains an empty string. + ErrEmptyStringInMethods = "empty string found in methods array; expected no empty strings to be passed; got: %v" + // ErrExceededAllowance is raised when the amount exceeds the set allowance. + ErrExceededAllowance = "amount %s greater than allowed limit %s" + // ErrInvalidGranter is raised when the granter address is not valid. + ErrInvalidGranter = "invalid granter address: %v" + // ErrInvalidGrantee is raised when the grantee address is not valid. + ErrInvalidGrantee = "invalid grantee address: %v" + // ErrInvalidMethods is raised when the given methods cannot be unpacked. + ErrInvalidMethods = "invalid methods defined; expected an array of strings; got: %v" + // ErrInvalidMethod is raised when the given method cannot be unpacked. + ErrInvalidMethod = "invalid method defined; expected a string; got: %v" + // ErrAuthzNotAccepted is raised when the authorization is not accepted. + ErrAuthzNotAccepted = "authorization to %s for address %s is not accepted" + + DelegateMethod = "delegate" + UndelegateMethod = "undelegate" + + // DelegateAuthz defines the authorization type for the staking Delegate + DelegateAuthz = stakingtypes.AuthorizationType_AUTHORIZATION_TYPE_DELEGATE + // UndelegateAuthz defines the authorization type for the staking Undelegate + UndelegateAuthz = stakingtypes.AuthorizationType_AUTHORIZATION_TYPE_UNDELEGATE + // RedelegateAuthz defines the authorization type for the staking Redelegate + RedelegateAuthz = stakingtypes.AuthorizationType_AUTHORIZATION_TYPE_REDELEGATE + // CancelUnbondingDelegationAuthz defines the authorization type for the staking + CancelUnbondingDelegationAuthz = stakingtypes.AuthorizationType_AUTHORIZATION_TYPE_CANCEL_UNBONDING_DELEGATION +) + +var ( + DelegateMsg = sdk.MsgTypeURL(&stakingtypes.MsgDelegate{}) + UndelegateMsg = sdk.MsgTypeURL(&stakingtypes.MsgUndelegate{}) +) + +func (p Precompile) Delegate( + ctx sdk.Context, + origin common.Address, + contract *vm.Contract, + stateDB vm.StateDB, + method *abi.Method, + args []interface{}, +) ([]byte, error) { + bondDemon, err := p.stakingKeeper.BondDenom(ctx) + if err != nil { + return nil, err + } + + msg, delegatorHexAddr, err := NewMsgDelegate(args, bondDemon) + if err != nil { + return nil, err + } + + p.Logger(ctx).Debug( + "tx called", + "method", method.Name, + "args", fmt.Sprintf( + "{ delegator_address: %s, validator_address: %s, amount: %s }", + delegatorHexAddr, + msg.ValidatorAddress, + msg.Amount.Amount, + ), + ) + + var ( + stakeAuthz *stakingtypes.StakeAuthorization + expiration *time.Time + isCallerOrigin = contract.CallerAddress == origin + isCallerDelegator = contract.CallerAddress == delegatorHexAddr + ) + + if isCallerDelegator { + delegatorHexAddr = origin + } else if origin != delegatorHexAddr { + return nil, fmt.Errorf(ErrDifferentOriginFromDelegator, origin.String(), delegatorHexAddr.String()) + } + + if !isCallerOrigin { + stakeAuthz, expiration, err = CheckAuthzAndAllowanceForGranter(ctx, p.AuthzKeeper, contract.CallerAddress, delegatorHexAddr, &msg.Amount, DelegateMsg) + if err != nil { + return nil, err + } + } + + msgSrv := stakingkeeper.NewMsgServerImpl(&p.stakingKeeper) + if _, err = msgSrv.Delegate(sdk.WrapSDKContext(ctx), msg); err != nil { + return nil, err + } + + if !isCallerOrigin { + if err := p.UpdateStakingAuthorization(ctx, contract.CallerAddress, delegatorHexAddr, stakeAuthz, expiration, DelegateMsg, msg); err != nil { + return nil, err + } + } + + if isCallerDelegator { + stateDB.(*statedb.StateDB).SubBalance(contract.CallerAddress, msg.Amount.Amount.BigInt()) + } + + return method.Outputs.Pack(true) +} + +func (p Precompile) Undelegate( + ctx sdk.Context, + origin common.Address, + contract *vm.Contract, + stateDB vm.StateDB, + method *abi.Method, + args []interface{}, +) ([]byte, error) { + bonddenom, err := p.stakingKeeper.BondDenom(ctx) + if err != nil { + return nil, err + } + + msg, delegatorHexAddr, err := NewMsgUndelegate(args, bonddenom) + if err != nil { + return nil, err + } + + p.Logger(ctx).Debug( + "tx called", + "method", method.Name, + "args", fmt.Sprintf( + "{ delegator_address: %s, validator_address: %s, amount: %s }", + delegatorHexAddr, + msg.ValidatorAddress, + msg.Amount.Amount, + ), + ) + + var ( + stakeAuthz *stakingtypes.StakeAuthorization + expiration *time.Time + ) + + if !(contract.CallerAddress == delegatorHexAddr) { + delegatorHexAddr = origin + } else if origin != delegatorHexAddr { + return nil, fmt.Errorf(ErrDifferentOriginFromDelegator, origin.String(), delegatorHexAddr.String()) + } + + if !(contract.CallerAddress == origin) { + stakeAuthz, expiration, err = CheckAuthzAndAllowanceForGranter(ctx, p.AuthzKeeper, contract.CallerAddress, delegatorHexAddr, &msg.Amount, UndelegateMsg) + if err != nil { + return nil, err + } + } + + res, err := stakingkeeper.NewMsgServerImpl(&p.stakingKeeper).Undelegate(sdk.WrapSDKContext(ctx), msg) + if err != nil { + return nil, err + } + + if !(contract.CallerAddress == origin) { + if err := p.UpdateStakingAuthorization(ctx, contract.CallerAddress, delegatorHexAddr, stakeAuthz, expiration, UndelegateMsg, msg); err != nil { + return nil, err + } + } + + return method.Outputs.Pack(res.CompletionTime.UTC().Unix()) +} + +func (p Precompile) UpdateStakingAuthorization( + ctx sdk.Context, + grantee, granter common.Address, + stakeAuthz *stakingtypes.StakeAuthorization, + expiration *time.Time, + messageType string, + msg sdk.Msg, +) error { + updatedResponse, err := stakeAuthz.Accept(ctx, msg) + if err != nil { + return err + } + + if updatedResponse.Delete { + err = p.AuthzKeeper.DeleteGrant(ctx, grantee.Bytes(), granter.Bytes(), messageType) + } else { + err = p.AuthzKeeper.SaveGrant(ctx, grantee.Bytes(), granter.Bytes(), updatedResponse.Updated, expiration) + } + + if err != nil { + return err + } + return nil +} + +func CheckAuthzAndAllowanceForGranter( + ctx sdk.Context, + authzKeeper authzkeeper.Keeper, + grantee, granter common.Address, + amount *sdk.Coin, + msgURL string, +) (*stakingtypes.StakeAuthorization, *time.Time, error) { + msgAuthz, expiration := authzKeeper.GetAuthorization(ctx, grantee.Bytes(), granter.Bytes(), msgURL) + if msgAuthz == nil { + return nil, nil, fmt.Errorf(ErrAuthzDoesNotExistOrExpired, msgURL, grantee) + } + + stakeAuthz, ok := msgAuthz.(*stakingtypes.StakeAuthorization) + if !ok { + return nil, nil, authz.ErrUnknownAuthorizationType + } + + if stakeAuthz.MaxTokens != nil && amount.Amount.GT(stakeAuthz.MaxTokens.Amount) { + return nil, nil, fmt.Errorf(ErrExceededAllowance, amount.Amount, stakeAuthz.MaxTokens.Amount) + } + + return stakeAuthz, expiration, nil +} diff --git a/precompiles/staking/types.go b/precompiles/staking/types.go new file mode 100644 index 0000000..274260e --- /dev/null +++ b/precompiles/staking/types.go @@ -0,0 +1,100 @@ +// Copyright Tharsis Labs Ltd.(Evmos) +// SPDX-License-Identifier:ENCL-1.0(https://github.com/evmos/evmos/blob/main/LICENSE) + +package staking + +import ( + "fmt" + "math/big" + + "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/ethereum/go-ethereum/common" +) + +const ( + // ErrNotRunInEvm is raised when a function is not called inside the EVM. + ErrNotRunInEvm = "not run in EVM" + // ErrDifferentOrigin is raised when an approval is set but the origin address is not the same as the spender. + ErrDifferentOrigin = "tx origin address %s does not match the delegator address %s" + // ErrInvalidABI is raised when the ABI cannot be parsed. + ErrInvalidABI = "invalid ABI: %w" + // ErrInvalidAmount is raised when the amount cannot be cast to a big.Int. + ErrInvalidAmount = "invalid amount: %v" + // ErrInvalidDelegator is raised when the delegator address is not valid. + ErrInvalidDelegator = "invalid delegator address: %s" + // ErrInvalidDenom is raised when the denom is not valid. + ErrInvalidDenom = "invalid denom: %s" + // ErrInvalidMsgType is raised when the transaction type is not valid for the given precompile. + ErrInvalidMsgType = "invalid %s transaction type: %s" + // ErrInvalidNumberOfArgs is raised when the number of arguments is not what is expected. + ErrInvalidNumberOfArgs = "invalid number of arguments; expected %d; got: %d" + // ErrUnknownMethod is raised when the method is not known. + ErrUnknownMethod = "unknown method: %s" + // ErrIntegerOverflow is raised when an integer overflow occurs. + ErrIntegerOverflow = "integer overflow" + // ErrNegativeAmount is raised when an amount is negative. + ErrNegativeAmount = "negative amount" + // ErrInvalidType is raised when the provided type is different than the expected. + ErrInvalidType = "invalid type for %s: expected %T, received %T" +) + +func NewMsgDelegate(args []interface{}, denom string) (*stakingtypes.MsgDelegate, common.Address, error) { + delegatorAddr, validatorAddress, amount, err := checkDelegationUndelegationArgs(args) + if err != nil { + return nil, common.Address{}, err + } + + msg := &stakingtypes.MsgDelegate{ + DelegatorAddress: sdk.AccAddress(delegatorAddr.Bytes()).String(), + ValidatorAddress: validatorAddress, + Amount: sdk.Coin{ + Denom: denom, + Amount: math.NewIntFromBigInt(amount), + }, + } + + return msg, delegatorAddr, nil +} + +func NewMsgUndelegate(args []interface{}, denom string) (*stakingtypes.MsgUndelegate, common.Address, error) { + delegatorAddr, validatorAddress, amount, err := checkDelegationUndelegationArgs(args) + if err != nil { + return nil, common.Address{}, err + } + + msg := &stakingtypes.MsgUndelegate{ + DelegatorAddress: sdk.AccAddress(delegatorAddr.Bytes()).String(), + ValidatorAddress: validatorAddress, + Amount: sdk.Coin{ + Denom: denom, + Amount: math.NewIntFromBigInt(amount), + }, + } + + return msg, delegatorAddr, nil +} + +func checkDelegationUndelegationArgs(args []interface{}) (common.Address, string, *big.Int, error) { + if len(args) != 3 { + return common.Address{}, "", nil, fmt.Errorf(ErrInvalidNumberOfArgs, 3, len(args)) + } + + delegatorAddr, ok := args[0].(common.Address) + if !ok || delegatorAddr == (common.Address{}) { + return common.Address{}, "", nil, fmt.Errorf(ErrInvalidDelegator, args[0]) + } + + validatorAddress, ok := args[1].(string) + if !ok { + return common.Address{}, "", nil, fmt.Errorf(ErrInvalidType, "validatorAddress", "string", args[1]) + } + + amount, ok := args[2].(*big.Int) + if !ok { + return common.Address{}, "", nil, fmt.Errorf(ErrInvalidAmount, args[2]) + } + + return delegatorAddr, validatorAddress, amount, nil +}