diff --git a/action/builder.go b/action/builder.go index 09708c50f7..7521ba0c7f 100644 --- a/action/builder.go +++ b/action/builder.go @@ -257,6 +257,9 @@ func newStakingActionFromABIBinary(data []byte) (actionPayload, error) { if act, err := NewCandidateTransferOwnershipFromABIBinary(data); err == nil { return act, nil } + if act, err := NewMigrateStakeFromABIBinary(data); err == nil { + return act, nil + } return nil, ErrInvalidABI } diff --git a/action/protocol/context.go b/action/protocol/context.go index bd58b8f761..2f86f04f50 100644 --- a/action/protocol/context.go +++ b/action/protocol/context.go @@ -122,6 +122,7 @@ type ( SuicideTxLogMismatchPanic bool PanicUnrecoverableError bool CandidateIdentifiedByOwner bool + MigrateNativeStake bool } // FeatureWithHeightCtx provides feature check functions. @@ -269,6 +270,7 @@ func WithFeatureCtx(ctx context.Context) context.Context { SuicideTxLogMismatchPanic: g.IsToBeEnabled(height), PanicUnrecoverableError: g.IsToBeEnabled(height), CandidateIdentifiedByOwner: !g.IsToBeEnabled(height), + MigrateNativeStake: g.IsToBeEnabled(height), }, ) } diff --git a/systemcontractindex/stakingindex/staking.json b/action/protocol/staking/contract_staking_abi.json similarity index 100% rename from systemcontractindex/stakingindex/staking.json rename to action/protocol/staking/contract_staking_abi.json diff --git a/action/protocol/staking/contractstake_indexer.go b/action/protocol/staking/contractstake_indexer.go index 5622777010..a33fa647ff 100644 --- a/action/protocol/staking/contractstake_indexer.go +++ b/action/protocol/staking/contractstake_indexer.go @@ -6,9 +6,21 @@ package staking import ( + _ "embed" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/iotexproject/iotex-address/address" ) +var ( + // StakingContractJSONABI is the abi json of staking contract + //go:embed contract_staking_abi.json + StakingContractJSONABI string + // StakingContractABI is the abi of staking contract + StakingContractABI abi.ABI +) + type ( // ContractStakingIndexer defines the interface of contract staking reader @@ -31,3 +43,11 @@ type ( BucketTypes(height uint64) ([]*ContractStakingBucketType, error) } ) + +func init() { + var err error + StakingContractABI, err = abi.JSON(strings.NewReader(StakingContractJSONABI)) + if err != nil { + panic(err) + } +} diff --git a/action/protocol/staking/handler_stake_migrate.go b/action/protocol/staking/handler_stake_migrate.go new file mode 100644 index 0000000000..61df06009b --- /dev/null +++ b/action/protocol/staking/handler_stake_migrate.go @@ -0,0 +1,181 @@ +package staking + +import ( + "context" + "math/big" + + "github.com/pkg/errors" + "go.uber.org/zap" + + "github.com/iotexproject/iotex-address/address" + "github.com/iotexproject/iotex-proto/golang/iotextypes" + + "github.com/iotexproject/iotex-core/action" + "github.com/iotexproject/iotex-core/action/protocol" + accountutil "github.com/iotexproject/iotex-core/action/protocol/account/util" + "github.com/iotexproject/iotex-core/pkg/log" + "github.com/iotexproject/iotex-core/pkg/util/byteutil" + "github.com/iotexproject/iotex-core/state" +) + +const ( + executionProtocolID = "smart_contract" +) + +type ( + executionProtocol interface { + Handle(ctx context.Context, act action.Action, sm protocol.StateManager) (*action.Receipt, error) + } +) + +func (p *Protocol) handleStakeMigrate(ctx context.Context, act *action.MigrateStake, csm CandidateStateManager) ([]*action.Log, []*action.TransactionLog, uint64, error) { + actLogs := make([]*action.Log, 0) + transferLogs := make([]*action.TransactionLog, 0) + si := csm.SM().Snapshot() + revertSM := func() { + if revertErr := csm.SM().Revert(si); revertErr != nil { + log.L().Panic("failed to revert state", zap.Error(revertErr)) + } + } + + // validate bucket index + bucket, rErr := p.fetchBucket(csm, act.BucketIndex()) + if rErr != nil { + return nil, nil, 0, rErr + } + if err := p.validateStakeMigrate(ctx, bucket, csm); err != nil { + return nil, nil, 0, err + } + + // force-withdraw native bucket + staker, rerr := fetchCaller(ctx, csm, big.NewInt(0)) + if rerr != nil { + return nil, nil, 0, errors.Wrap(rerr, "failed to fetch caller") + } + candidate := csm.GetByIdentifier(bucket.Candidate) + if candidate == nil { + return nil, nil, 0, errCandNotExist + } + actLog, tLog, err := p.withdrawBucket(ctx, staker, bucket, candidate, csm) + if err != nil { + return nil, nil, 0, err + } + actLogs = append(actLogs, actLog.Build(ctx, nil)) + transferLogs = append(transferLogs, tLog) + + // call staking contract to stake + duration := int64(bucket.StakedDuration / p.getBlockInterval(protocol.MustGetBlockCtx(ctx).BlockHeight)) + excReceipt, err := p.createNFTBucket(ctx, act, bucket.StakedAmount, big.NewInt(duration), candidate, csm.SM()) + if err != nil { + revertSM() + return nil, nil, 0, errors.Wrap(err, "failed to handle execution action") + } + if excReceipt.Status != uint64(iotextypes.ReceiptStatus_Success) { + // TODO: return err or handle error? + revertSM() + return nil, nil, 0, errors.Errorf("execution failed with status %d", excReceipt.Status) + } + // add sub-receipts logs + actLogs = append(actLogs, excReceipt.Logs()...) + transferLogs = append(transferLogs, excReceipt.TransactionLogs()...) + return actLogs, transferLogs, excReceipt.GasConsumed, nil +} + +func (p *Protocol) validateStakeMigrate(ctx context.Context, bucket *VoteBucket, csm CandidateStateManager) error { + if err := validateBucketOwner(bucket, protocol.MustGetActionCtx(ctx).Caller); err != nil { + return err + } + if !bucket.AutoStake { + return &handleError{ + err: errors.New("cannot migrate non-auto-staked bucket"), + failureStatus: iotextypes.ReceiptStatus_ErrInvalidBucketType, + } + } + if bucket.isUnstaked() { + return &handleError{ + err: errors.New("cannot migrate unstaked bucket"), + failureStatus: iotextypes.ReceiptStatus_ErrInvalidBucketType, + } + } + if err := validateBucketWithoutEndorsement(NewEndorsementStateManager(csm.SM()), bucket, protocol.MustGetBlockCtx(ctx).BlockHeight); err != nil { + return err + } + return validateBucketSelfStake(protocol.MustGetFeatureCtx(ctx), csm, bucket, false) +} + +func (p *Protocol) withdrawBucket(ctx context.Context, withdrawer *state.Account, bucket *VoteBucket, cand *Candidate, csm CandidateStateManager) (*receiptLog, *action.TransactionLog, error) { + // delete bucket and bucket index + if err := csm.delBucketAndIndex(bucket.Owner, bucket.Candidate, bucket.Index); err != nil { + return nil, nil, errors.Wrapf(err, "failed to delete bucket for candidate %s", bucket.Candidate.String()) + } + + // update bucket pool + if err := csm.CreditBucketPool(bucket.StakedAmount); err != nil { + return nil, nil, errors.Wrapf(err, "failed to update staking bucket pool %s", err.Error()) + } + // update candidate vote + weightedVote := p.calculateVoteWeight(bucket, false) + if err := cand.SubVote(weightedVote); err != nil { + return nil, nil, &handleError{ + err: errors.Wrapf(err, "failed to subtract vote for candidate %s", bucket.Candidate.String()), + failureStatus: iotextypes.ReceiptStatus_ErrNotEnoughBalance, + } + } + // clear candidate's self stake if the + if cand.SelfStakeBucketIdx == bucket.Index { + cand.SelfStake = big.NewInt(0) + cand.SelfStakeBucketIdx = candidateNoSelfStakeBucketIndex + } + if err := csm.Upsert(cand); err != nil { + return nil, nil, csmErrorToHandleError(cand.GetIdentifier().String(), err) + } + // update withdrawer balance + if err := withdrawer.AddBalance(bucket.StakedAmount); err != nil { + return nil, nil, errors.Wrapf(err, "failed to add balance %s", bucket.StakedAmount) + } + // put updated withdrawer's account state to trie + actionCtx := protocol.MustGetActionCtx(ctx) + if err := accountutil.StoreAccount(csm.SM(), actionCtx.Caller, withdrawer); err != nil { + return nil, nil, errors.Wrapf(err, "failed to store account %s", actionCtx.Caller.String()) + } + // create receipt log + actLog := newReceiptLog(p.addr.String(), handleCandidateActivate, protocol.MustGetFeatureCtx(ctx).NewStakingReceiptFormat) + actLog.AddTopics(byteutil.Uint64ToBytesBigEndian(bucket.Index), bucket.Candidate.Bytes()) + actLog.AddAddress(actionCtx.Caller) + actLog.SetData(bucket.StakedAmount.Bytes()) + return actLog, &action.TransactionLog{ + Type: iotextypes.TransactionLogType_WITHDRAW_BUCKET, + Amount: bucket.StakedAmount, + Sender: address.StakingBucketPoolAddr, + Recipient: actionCtx.Caller.String(), + }, nil +} + +func (p *Protocol) createNFTBucket(ctx context.Context, act *action.MigrateStake, amount, duration *big.Int, cand *Candidate, sm protocol.StateManager) (*action.Receipt, error) { + ptl, ok := protocol.MustGetRegistry(ctx).Find(executionProtocolID) + if !ok { + return nil, errors.New("execution protocol is not registered") + } + exctPtl, ok := ptl.(executionProtocol) + contractAddress := p.config.MigrateContractAddress + data, err := StakingContractABI.Pack("stake0", duration, cand.GetIdentifier()) + if err != nil { + return nil, errors.Wrap(err, "failed to pack data for contract call") + } + exeAct, err := action.NewExecution( + contractAddress, + act.Nonce(), + amount, + act.GasLimit(), + act.GasPrice(), + data, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create execution action") + } + excReceipt, err := exctPtl.Handle(ctx, exeAct, sm) + if err != nil { + return nil, errors.Wrap(err, "failed to handle execution action") + } + return excReceipt, nil +} diff --git a/action/protocol/staking/protocol.go b/action/protocol/staking/protocol.go index a4fca2e5e1..928e5ad16f 100644 --- a/action/protocol/staking/protocol.go +++ b/action/protocol/staking/protocol.go @@ -83,6 +83,7 @@ type ( contractStakingIndexer ContractStakingIndexerWithBucketType voteReviser *VoteReviser patch *PatchStore + getBlockInterval func(uint64) time.Duration } // Configuration is the staking protocol configuration. @@ -94,6 +95,7 @@ type ( BootstrapCandidates []genesis.BootstrapCandidate PersistStakingPatchBlock uint64 EndorsementWithdrawWaitingBlocks uint64 + MigrateContractAddress string } // DepositGas deposits gas to some pool @@ -396,10 +398,12 @@ func (p *Protocol) Handle(ctx context.Context, act action.Action, sm protocol.St func (p *Protocol) handle(ctx context.Context, act action.Action, csm CandidateStateManager) (*action.Receipt, error) { var ( - rLog *receiptLog - tLogs []*action.TransactionLog - err error - logs []*action.Log + rLog *receiptLog + tLogs []*action.TransactionLog + err error + logs []*action.Log + actionCtx = protocol.MustGetActionCtx(ctx) + gasConsumed = actionCtx.IntrinsicGas ) switch act := act.(type) { @@ -427,22 +431,25 @@ func (p *Protocol) handle(ctx context.Context, act action.Action, csm CandidateS rLog, tLogs, err = p.handleCandidateEndorsement(ctx, act, csm) case *action.CandidateTransferOwnership: rLog, tLogs, err = p.handleCandidateTransferOwnership(ctx, act, csm) + case *action.MigrateStake: + logs, tLogs, gasConsumed, err = p.handleStakeMigrate(ctx, act, csm) default: return nil, nil } - - if l := rLog.Build(ctx, err); l != nil { - logs = append(logs, l) + if rLog != nil { + if l := rLog.Build(ctx, err); l != nil { + logs = append(logs, l) + } } if err == nil { - return p.settleAction(ctx, csm.SM(), uint64(iotextypes.ReceiptStatus_Success), logs, tLogs) + return p.settleAction(ctx, csm.SM(), uint64(iotextypes.ReceiptStatus_Success), logs, tLogs, gasConsumed) } if receiptErr, ok := err.(ReceiptError); ok { actionCtx := protocol.MustGetActionCtx(ctx) log.L().With( zap.String("actionHash", hex.EncodeToString(actionCtx.ActionHash[:]))).Debug("Failed to commit staking action", zap.Error(err)) - return p.settleAction(ctx, csm.SM(), receiptErr.ReceiptStatus(), logs, tLogs) + return p.settleAction(ctx, csm.SM(), receiptErr.ReceiptStatus(), logs, tLogs, gasConsumed) } return nil, err } @@ -477,6 +484,8 @@ func (p *Protocol) Validate(ctx context.Context, act action.Action, sr protocol. return p.validateCandidateEndorsement(ctx, act) case *action.CandidateTransferOwnership: return p.validateCandidateTransferOwnershipAction(ctx, act) + case *action.MigrateStake: + return p.validateMigrateStake(ctx, act) } return nil } @@ -649,6 +658,7 @@ func (p *Protocol) settleAction( status uint64, logs []*action.Log, tLogs []*action.TransactionLog, + gasConsumed uint64, ) (*action.Receipt, error) { actionCtx := protocol.MustGetActionCtx(ctx) blkCtx := protocol.MustGetBlockCtx(ctx) @@ -675,7 +685,7 @@ func (p *Protocol) settleAction( Status: status, BlockHeight: blkCtx.BlockHeight, ActionHash: actionCtx.ActionHash, - GasConsumed: actionCtx.IntrinsicGas, + GasConsumed: gasConsumed, ContractAddress: p.addr.String(), } r.AddLogs(logs...).AddTransactionLogs(depositLog).AddTransactionLogs(tLogs...) diff --git a/action/protocol/staking/validations.go b/action/protocol/staking/validations.go index 2008f4b058..f78749d5ee 100644 --- a/action/protocol/staking/validations.go +++ b/action/protocol/staking/validations.go @@ -106,3 +106,10 @@ func (p *Protocol) validateCandidateTransferOwnershipAction(ctx context.Context, } return nil } + +func (p *Protocol) validateMigrateStake(ctx context.Context, act *action.MigrateStake) error { + if !protocol.MustGetFeatureCtx(ctx).MigrateNativeStake { + return errors.Wrap(action.ErrInvalidAct, "migrate stake is disabled") + } + return nil +} diff --git a/action/stake_migrate.go b/action/stake_migrate.go new file mode 100644 index 0000000000..dd518153eb --- /dev/null +++ b/action/stake_migrate.go @@ -0,0 +1,161 @@ +package action + +import ( + "bytes" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/core/types" + "google.golang.org/protobuf/proto" + + "github.com/iotexproject/iotex-proto/golang/iotextypes" + + "github.com/iotexproject/iotex-core/pkg/util/byteutil" + "github.com/iotexproject/iotex-core/pkg/version" +) + +const ( + // MigrateStakePayloadGas represents the MigrateStake payload gas per uint + MigrateStakePayloadGas = uint64(100) + // MigrateStakeBaseIntrinsicGas represents the base intrinsic gas for MigrateStake + MigrateStakeBaseIntrinsicGas = uint64(10000) + + migrateStakeInterfaceABI = `[ + { + "inputs": [ + { + "internalType": "uint64", + "name": "bucketIndex", + "type": "uint64" + } + ], + "name": "migrateStake", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ]` +) + +var ( + // migrateStakeMethod is the interface of the abi encoding of migrate stake action + migrateStakeMethod abi.Method + _ EthCompatibleAction = (*MigrateStake)(nil) +) + +type MigrateStake struct { + AbstractAction + + bucketIndex uint64 +} + +func init() { + migrateInterface, err := abi.JSON(strings.NewReader(migrateStakeInterfaceABI)) + if err != nil { + panic(err) + } + var ok bool + migrateStakeMethod, ok = migrateInterface.Methods["migrateStake"] + if !ok { + panic("fail to load the migrateStake method") + } +} + +// NewMigrateStake returns a MigrateStake instance +func NewMigrateStake( + nonce uint64, + index uint64, + gasLimit uint64, + gasPrice *big.Int, +) (*MigrateStake, error) { + return &MigrateStake{ + AbstractAction: AbstractAction{ + version: version.ProtocolVersion, + nonce: nonce, + gasLimit: gasLimit, + gasPrice: gasPrice, + }, + bucketIndex: index, + }, nil +} + +// BucketIndex returns bucket index +func (ms *MigrateStake) BucketIndex() uint64 { return ms.bucketIndex } + +// IntrinsicGas returns the intrinsic gas of a Restake +func (ms *MigrateStake) IntrinsicGas() (uint64, error) { + return CalculateIntrinsicGas(MigrateStakeBaseIntrinsicGas, MigrateStakePayloadGas, 0) +} + +// Cost returns the total cost of a MigrateStake +func (ms *MigrateStake) Cost() (*big.Int, error) { + maxExecFee := big.NewInt(0).Mul(ms.GasPrice(), big.NewInt(0).SetUint64(ms.GasLimit())) + return maxExecFee, nil +} + +// Serialize returns a raw byte stream of the Stake again struct +func (ms *MigrateStake) Serialize() []byte { + return byteutil.Must(proto.Marshal(ms.Proto())) +} + +// Proto converts to protobuf Restake Action +func (ms *MigrateStake) Proto() *iotextypes.StakeMigrate { + act := &iotextypes.StakeMigrate{ + BucketIndex: ms.bucketIndex, + } + return act +} + +// LoadProto converts a protobuf's Action to Restake +func (ms *MigrateStake) LoadProto(pbAct *iotextypes.StakeMigrate) error { + if pbAct == nil { + return ErrNilProto + } + ms.bucketIndex = pbAct.GetBucketIndex() + return nil +} + +func (ms *MigrateStake) encodeABIBinary() ([]byte, error) { + data, err := migrateStakeMethod.Inputs.Pack(ms.bucketIndex) + if err != nil { + return nil, err + } + return append(migrateStakeMethod.ID, data...), nil +} + +// NewMigrateStakeFromABIBinary decodes data into MigrateStake +func NewMigrateStakeFromABIBinary(data []byte) (*MigrateStake, error) { + var ( + paramsMap = map[string]interface{}{} + ok bool + rs MigrateStake + ) + // sanity check + if len(data) <= 4 || !bytes.Equal(migrateStakeMethod.ID, data[:4]) { + return nil, errDecodeFailure + } + if err := migrateStakeMethod.Inputs.UnpackIntoMap(paramsMap, data[4:]); err != nil { + return nil, err + } + if rs.bucketIndex, ok = paramsMap["bucketIndex"].(uint64); !ok { + return nil, errDecodeFailure + } + return &rs, nil +} + +// ToEthTx converts action to eth-compatible tx +func (ms *MigrateStake) ToEthTx(_ uint32) (*types.Transaction, error) { + data, err := ms.encodeABIBinary() + if err != nil { + return nil, err + } + return types.NewTx(&types.LegacyTx{ + Nonce: ms.Nonce(), + GasPrice: ms.GasPrice(), + Gas: ms.GasLimit(), + To: &_stakingProtocolEthAddr, + Value: big.NewInt(0), + Data: data, + }), nil +} diff --git a/go.mod b/go.mod index b83513fe4c..fddf05f082 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/iotexproject/iotex-address v0.2.8 github.com/iotexproject/iotex-antenna-go/v2 v2.5.1 github.com/iotexproject/iotex-election v0.3.5-0.20210611041425-20ddf674363d - github.com/iotexproject/iotex-proto v0.6.1 + github.com/iotexproject/iotex-proto v0.6.2-0.20240611111727-b9d851578a05 github.com/ipfs/go-ipfs-api v0.2.0 github.com/libp2p/go-libp2p-core v0.8.5 github.com/mackerelio/go-osstat v0.2.4 diff --git a/go.sum b/go.sum index 027918776d..12235115a4 100644 --- a/go.sum +++ b/go.sum @@ -666,6 +666,8 @@ github.com/iotexproject/iotex-election v0.3.5-0.20210611041425-20ddf674363d/go.m github.com/iotexproject/iotex-proto v0.5.0/go.mod h1:Xg6REkv+nTZN+OC22xXIQuqKdTWWHwOAJEXCoMpDwtI= github.com/iotexproject/iotex-proto v0.6.1 h1:eUKd2x7zL94ctH8EBJ5O2ZaK8fweCgOkLyPKmPviFgc= github.com/iotexproject/iotex-proto v0.6.1/go.mod h1:wQpCk3Df0fPID+K8ohiICGj+cWRmcQ3wanT+aSrnIPo= +github.com/iotexproject/iotex-proto v0.6.2-0.20240611111727-b9d851578a05 h1:tlpn5kUAlsIpfw6sHTfmzk58gMglNjHbRiy+TipFR5s= +github.com/iotexproject/iotex-proto v0.6.2-0.20240611111727-b9d851578a05/go.mod h1:wQpCk3Df0fPID+K8ohiICGj+cWRmcQ3wanT+aSrnIPo= github.com/ipfs/go-cid v0.0.1/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= github.com/ipfs/go-cid v0.0.2/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= github.com/ipfs/go-cid v0.0.3/go.mod h1:GHWU/WuQdMPmIosc4Yn1bcCT7dSeX4lBafM7iqUPQvM= diff --git a/systemcontractindex/stakingindex/event_handler.go b/systemcontractindex/stakingindex/event_handler.go index 88ce14f015..ce2dd2276d 100644 --- a/systemcontractindex/stakingindex/event_handler.go +++ b/systemcontractindex/stakingindex/event_handler.go @@ -4,9 +4,7 @@ import ( "context" _ "embed" "math" - "strings" - "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/pkg/errors" "go.uber.org/zap" @@ -14,6 +12,7 @@ import ( "github.com/iotexproject/iotex-address/address" "github.com/iotexproject/iotex-core/action" + "github.com/iotexproject/iotex-core/action/protocol/staking" "github.com/iotexproject/iotex-core/blockchain/block" "github.com/iotexproject/iotex-core/db/batch" "github.com/iotexproject/iotex-core/pkg/log" @@ -32,23 +31,12 @@ type eventHandler struct { } var ( - // TODO: fill in the ABI of staking contract - //go:embed staking.json - StakingContractJSONABI string - stakingContractABI abi.ABI + stakingContractABI = staking.StakingContractABI // ErrBucketNotExist is the error when bucket does not exist ErrBucketNotExist = errors.New("bucket does not exist") ) -func init() { - var err error - stakingContractABI, err = abi.JSON(strings.NewReader(StakingContractJSONABI)) - if err != nil { - panic(err) - } -} - func newEventHandler(dirty *cache) *eventHandler { return &eventHandler{ dirty: dirty,