diff --git a/action/candidate_register.go b/action/candidate_register.go index 5dfe9b12a9..2b01f28a73 100644 --- a/action/candidate_register.go +++ b/action/candidate_register.go @@ -13,13 +13,13 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/iotexproject/iotex-address/address" + "github.com/iotexproject/iotex-proto/golang/iotextypes" "github.com/pkg/errors" "google.golang.org/protobuf/proto" - "github.com/iotexproject/iotex-address/address" "github.com/iotexproject/iotex-core/pkg/util/byteutil" "github.com/iotexproject/iotex-core/pkg/version" - "github.com/iotexproject/iotex-proto/golang/iotextypes" ) const ( @@ -290,7 +290,7 @@ func (cr *CandidateRegister) Cost() (*big.Int, error) { // SanityCheck validates the variables in the action func (cr *CandidateRegister) SanityCheck() error { - if cr.Amount().Sign() <= 0 { + if cr.Amount().Sign() < 0 { return errors.Wrap(ErrInvalidAmount, "negative value") } if !IsValidCandidateName(cr.Name()) { diff --git a/action/protocol/context.go b/action/protocol/context.go index 31bea76f64..1ee44c4b4a 100644 --- a/action/protocol/context.go +++ b/action/protocol/context.go @@ -116,6 +116,7 @@ type ( ExecutionSizeLimit32KB bool UseZeroNonceForFreshAccount bool SharedGasWithDapp bool + CandidateRegisterMustWithStake bool DisableDelegateEndorsement bool } @@ -258,6 +259,7 @@ func WithFeatureCtx(ctx context.Context) context.Context { ExecutionSizeLimit32KB: !g.IsSumatra(height), UseZeroNonceForFreshAccount: g.IsSumatra(height), SharedGasWithDapp: g.IsToBeEnabled(height), + CandidateRegisterMustWithStake: !g.IsToBeEnabled(height), DisableDelegateEndorsement: !g.IsToBeEnabled(height), }, ) diff --git a/action/protocol/staking/handlers.go b/action/protocol/staking/handlers.go index e8b920ae47..37aed9b464 100644 --- a/action/protocol/staking/handlers.go +++ b/action/protocol/staking/handlers.go @@ -702,10 +702,31 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand } } - bucket := NewVoteBucket(owner, owner, act.Amount(), act.Duration(), blkCtx.BlockTimeStamp, act.AutoStake()) - bucketIdx, err := csm.putBucketAndIndex(bucket) - if err != nil { - return log, nil, err + var ( + bucketIdx uint64 + votes *big.Int + withSelfStake = act.Amount().Sign() > 0 + txLogs []*action.TransactionLog + err error + ) + if withSelfStake { + // register with self-stake + bucket := NewVoteBucket(owner, owner, act.Amount(), act.Duration(), blkCtx.BlockTimeStamp, act.AutoStake()) + bucketIdx, err = csm.putBucketAndIndex(bucket) + if err != nil { + return log, nil, err + } + txLogs = append(txLogs, &action.TransactionLog{ + Type: iotextypes.TransactionLogType_CANDIDATE_SELF_STAKE, + Sender: actCtx.Caller.String(), + Recipient: address.StakingBucketPoolAddr, + Amount: act.Amount(), + }) + votes = p.calculateVoteWeight(bucket, true) + } else { + // register w/o self-stake, waiting to be endorsed + bucketIdx = uint64(candidateNoSelfStakeBucketIndex) + votes = big.NewInt(0) } log.AddTopics(byteutil.Uint64ToBytesBigEndian(bucketIdx), owner.Bytes()) @@ -714,7 +735,7 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand Operator: act.OperatorAddress(), Reward: act.RewardAddress(), Name: act.Name(), - Votes: p.calculateVoteWeight(bucket, true), + Votes: votes, SelfStakeBucketIdx: bucketIdx, SelfStake: act.Amount(), } @@ -727,28 +748,29 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand csm.DirtyView().candCenter.base.recordOwner(c) } - // update bucket pool - if err := csm.DebitBucketPool(act.Amount(), true); err != nil { - return log, nil, &handleError{ - err: errors.Wrapf(err, "failed to update staking bucket pool %s", err.Error()), - failureStatus: iotextypes.ReceiptStatus_ErrWriteAccount, + if withSelfStake { + // update bucket pool + if err := csm.DebitBucketPool(act.Amount(), true); err != nil { + return log, nil, &handleError{ + err: errors.Wrapf(err, "failed to update staking bucket pool %s", err.Error()), + failureStatus: iotextypes.ReceiptStatus_ErrWriteAccount, + } } - } - - // update caller balance - if err := caller.SubBalance(act.Amount()); err != nil { - return log, nil, &handleError{ - err: errors.Wrapf(err, "failed to update the balance of register %s", actCtx.Caller.String()), - failureStatus: iotextypes.ReceiptStatus_ErrNotEnoughBalance, + // update caller balance + if err := caller.SubBalance(act.Amount()); err != nil { + return log, nil, &handleError{ + err: errors.Wrapf(err, "failed to update the balance of register %s", actCtx.Caller.String()), + failureStatus: iotextypes.ReceiptStatus_ErrNotEnoughBalance, + } + } + // put updated caller's account state to trie + if err := accountutil.StoreAccount(csm.SM(), actCtx.Caller, caller); err != nil { + return log, nil, errors.Wrapf(err, "failed to store account %s", actCtx.Caller.String()) } - } - // put updated caller's account state to trie - if err := accountutil.StoreAccount(csm.SM(), actCtx.Caller, caller); err != nil { - return log, nil, errors.Wrapf(err, "failed to store account %s", actCtx.Caller.String()) } // put registrationFee to reward pool - if _, err = p.depositGas(ctx, csm.SM(), registrationFee); err != nil { + if _, err := p.depositGas(ctx, csm.SM(), registrationFee); err != nil { return log, nil, errors.Wrap(err, "failed to deposit gas") } @@ -756,20 +778,13 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand log.AddAddress(actCtx.Caller) log.SetData(byteutil.Uint64ToBytesBigEndian(bucketIdx)) - return log, []*action.TransactionLog{ - { - Type: iotextypes.TransactionLogType_CANDIDATE_SELF_STAKE, - Sender: actCtx.Caller.String(), - Recipient: address.StakingBucketPoolAddr, - Amount: act.Amount(), - }, - { - Type: iotextypes.TransactionLogType_CANDIDATE_REGISTRATION_FEE, - Sender: actCtx.Caller.String(), - Recipient: address.RewardingPoolAddr, - Amount: registrationFee, - }, - }, nil + txLogs = append(txLogs, &action.TransactionLog{ + Type: iotextypes.TransactionLogType_CANDIDATE_REGISTRATION_FEE, + Sender: actCtx.Caller.String(), + Recipient: address.RewardingPoolAddr, + Amount: registrationFee, + }) + return log, txLogs, nil } func (p *Protocol) handleCandidateUpdate(ctx context.Context, act *action.CandidateUpdate, csm CandidateStateManager, diff --git a/action/protocol/staking/handlers_test.go b/action/protocol/staking/handlers_test.go index 4746fb5019..714454a9bf 100644 --- a/action/protocol/staking/handlers_test.go +++ b/action/protocol/staking/handlers_test.go @@ -533,6 +533,27 @@ func TestProtocol_HandleCandidateRegister(t *testing.T) { nil, iotextypes.ReceiptStatus_ErrCandidateConflict, }, + // register without self-stake + { + 1201000, + identityset.Address(27), + 1, + "test", + identityset.Address(28).String(), + identityset.Address(29).String(), + identityset.Address(30).String(), + "0", + "0", + uint32(10000), + false, + nil, + uint64(1000000), + uint64(1000000), + big.NewInt(1), + true, + nil, + iotextypes.ReceiptStatus_Success, + }, } for _, test := range tests { @@ -555,7 +576,9 @@ func TestProtocol_HandleCandidateRegister(t *testing.T) { BlockTimeStamp: time.Now(), GasLimit: test.blkGasLimit, }) - ctx = genesis.WithGenesisContext(ctx, genesis.Default) + g := deepcopy.Copy(genesis.Default).(genesis.Genesis) + g.ToBeEnabledBlockHeight = 0 + ctx = genesis.WithGenesisContext(ctx, g) ctx = protocol.WithFeatureCtx(protocol.WithFeatureWithHeightCtx(ctx)) require.Equal(test.err, errors.Cause(p.Validate(ctx, act, sm))) if test.err != nil { @@ -572,16 +595,24 @@ func TestProtocol_HandleCandidateRegister(t *testing.T) { if test.err == nil && test.status == iotextypes.ReceiptStatus_Success { // check the special create bucket and candidate register log tLogs := r.TransactionLogs() - require.Equal(2, len(tLogs)) - cLog := tLogs[0] - require.Equal(test.caller.String(), cLog.Sender) - require.Equal(address.StakingBucketPoolAddr, cLog.Recipient) - require.Equal(test.amountStr, cLog.Amount.String()) - - cLog = tLogs[1] - require.Equal(test.caller.String(), cLog.Sender) - require.Equal(address.RewardingPoolAddr, cLog.Recipient) - require.Equal(p.config.RegistrationConsts.Fee.String(), cLog.Amount.String()) + if test.amountStr == "0" { + require.Equal(1, len(tLogs)) + cLog := tLogs[0] + require.Equal(test.caller.String(), cLog.Sender) + require.Equal(address.RewardingPoolAddr, cLog.Recipient) + require.Equal(p.config.RegistrationConsts.Fee.String(), cLog.Amount.String()) + } else { + require.Equal(2, len(tLogs)) + cLog := tLogs[0] + require.Equal(test.caller.String(), cLog.Sender) + require.Equal(address.StakingBucketPoolAddr, cLog.Recipient) + require.Equal(test.amountStr, cLog.Amount.String()) + + cLog = tLogs[1] + require.Equal(test.caller.String(), cLog.Sender) + require.Equal(address.RewardingPoolAddr, cLog.Recipient) + require.Equal(p.config.RegistrationConsts.Fee.String(), cLog.Amount.String()) + } // test candidate candidate, _, err := csr.getCandidate(act.OwnerAddress()) diff --git a/action/protocol/staking/validations.go b/action/protocol/staking/validations.go index 20b155442c..2b0acc41ae 100644 --- a/action/protocol/staking/validations.go +++ b/action/protocol/staking/validations.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" "github.com/iotexproject/iotex-core/action" + "github.com/iotexproject/iotex-core/action/protocol" ) // Errors @@ -66,6 +67,10 @@ func (p *Protocol) validateCandidateRegister(ctx context.Context, act *action.Ca } if act.Amount().Cmp(p.config.RegistrationConsts.MinSelfStake) < 0 { + if !protocol.MustGetFeatureCtx(ctx).CandidateRegisterMustWithStake && + act.Amount().Sign() == 0 { + return nil + } return errors.Wrap(action.ErrInvalidAmount, "self staking amount is not valid") } return nil diff --git a/action/signedaction.go b/action/signedaction.go index d521324821..ffecc58f5d 100644 --- a/action/signedaction.go +++ b/action/signedaction.go @@ -107,6 +107,49 @@ func SignedCandidateUpdate( return selp, nil } +// SignedCandidateActivate returns a signed candidate selfstake +func SignedCandidateActivate( + nonce uint64, + bucketIndex uint64, + gasLimit uint64, + gasPrice *big.Int, + registererPriKey crypto.PrivateKey, +) (*SealedEnvelope, error) { + cu := NewCandidateActivate(nonce, gasLimit, gasPrice, bucketIndex) + bd := &EnvelopeBuilder{} + elp := bd.SetNonce(nonce). + SetGasPrice(gasPrice). + SetGasLimit(gasLimit). + SetAction(cu).Build() + selp, err := Sign(elp, registererPriKey) + if err != nil { + return nil, errors.Wrapf(err, "failed to sign candidate selfstake %v", elp) + } + return selp, nil +} + +// SignedCandidateEndorsement returns a signed candidate endorsement +func SignedCandidateEndorsement( + nonce uint64, + bucketIndex uint64, + endorse bool, + gasLimit uint64, + gasPrice *big.Int, + registererPriKey crypto.PrivateKey, +) (*SealedEnvelope, error) { + cu := NewCandidateEndorsement(nonce, gasLimit, gasPrice, bucketIndex, endorse) + bd := &EnvelopeBuilder{} + elp := bd.SetNonce(nonce). + SetGasPrice(gasPrice). + SetGasLimit(gasLimit). + SetAction(cu).Build() + selp, err := Sign(elp, registererPriKey) + if err != nil { + return nil, errors.Wrapf(err, "failed to sign candidate endorsement %v", elp) + } + return selp, nil +} + // SignedCreateStake returns a signed create stake func SignedCreateStake(nonce uint64, candidateName, amount string, diff --git a/e2etest/native_staking_test.go b/e2etest/native_staking_test.go index 8d1c250eb0..861fb364b8 100644 --- a/e2etest/native_staking_test.go +++ b/e2etest/native_staking_test.go @@ -39,6 +39,7 @@ const ( candidate1Name = "candidate1" candidate2Name = "candidate2" + candidate3Name = "candidate3" ) var ( @@ -91,6 +92,9 @@ func TestNativeStaking(t *testing.T) { ap := svr.ChainService(chainID).ActionPool() dao := svr.ChainService(chainID).BlockDAO() require.NotNil(bc) + prtcl, ok := svr.ChainService(chainID).Registry().Find("staking") + require.True(ok) + stkPrtcl := prtcl.(*staking.Protocol) require.True(cfg.Genesis.IsFbkMigration(1)) @@ -101,6 +105,10 @@ func TestNativeStaking(t *testing.T) { cand2Addr := identityset.Address(1) cand2PriKey := identityset.PrivateKey(1) + // create non-stake candidate + cand3Addr := identityset.Address(4) + cand3PriKey := identityset.PrivateKey(4) + fixedTime := time.Unix(cfg.Genesis.Timestamp, 0) addOneTx := func(tx *action.SealedEnvelope, err error) (*action.SealedEnvelope, error) { if err != nil { @@ -392,6 +400,108 @@ func TestNativeStaking(t *testing.T) { // check candidate account state require.NoError(checkAccountState(cfg, sf, ws, true, big.NewInt(0).Sub(initBalance, selfStake), cand1Addr)) + + // register without stake + register3, err := addOneTx(action.SignedCandidateRegister(1, candidate3Name, cand3Addr.String(), cand3Addr.String(), + cand3Addr.String(), "0", 1, false, nil, gasLimit, gasPrice, cand3PriKey)) + require.NoError(err) + register3Hash, err := register3.Hash() + require.NoError(err) + r3, err := dao.GetReceiptByActionHash(register3Hash, bc.TipHeight()) + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, r3.Status) + require.NoError(checkCandidateState(sf, candidate3Name, cand3Addr.String(), big.NewInt(0), big.NewInt(0), cand3Addr)) + require.NoError(checkAccountState(cfg, sf, register3, true, initBalance, cand3Addr)) + + ctx, err = bc.Context(ctx) + require.NoError(err) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: bc.TipHeight() + 1, + }) + ctx = protocol.WithFeatureCtx(ctx) + cands, err := stkPrtcl.ActiveCandidates(ctx, sf, 0) + require.NoError(err) + require.Equal(4, len(cands)) + for _, cand := range cands { + t.Logf("\ncandidate=%+v, %+v\n", string(cand.CanName), cand.Votes.String()) + } + // stake bucket + cs3, err := addOneTx(action.SignedCreateStake(3, candidate3Name, selfStake.String(), 1, false, + nil, gasLimit, gasPrice, voter1PriKey)) + require.NoError(err) + cs3Hash, err := cs3.Hash() + require.NoError(err) + r1, err = dao.GetReceiptByActionHash(cs3Hash, bc.TipHeight()) + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, r1.Status) + logs = r1.Logs() + require.Equal(3, len(logs[0].Topics)) + require.Equal(hash.BytesToHash256([]byte(staking.HandleCreateStake)), logs[0].Topics[0]) + endorseBucketIndex := byteutil.BytesToUint64BigEndian(logs[0].Topics[1][24:]) + t.Logf("endorseBucketIndex=%+v", endorseBucketIndex) + // endorse bucket + es, err := addOneTx(action.SignedCandidateEndorsement(4, endorseBucketIndex, true, gasLimit, gasPrice, voter1PriKey)) + require.NoError(err) + esHash, err := es.Hash() + require.NoError(err) + r1, err = dao.GetReceiptByActionHash(esHash, bc.TipHeight()) + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, r1.Status) + // candidate self stake + css, err := addOneTx(action.SignedCandidateActivate(2, endorseBucketIndex, gasLimit, gasPrice, cand3PriKey)) + require.NoError(err) + cssHash, err := css.Hash() + require.NoError(err) + r1, err = dao.GetReceiptByActionHash(cssHash, bc.TipHeight()) + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, r1.Status) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: bc.TipHeight() + 1, + }) + ctx = protocol.WithFeatureCtx(ctx) + cands, err = stkPrtcl.ActiveCandidates(ctx, sf, 0) + require.NoError(err) + require.Equal(5, len(cands)) + for _, cand := range cands { + t.Logf("\ncandidate=%+v, %+v\n", string(cand.CanName), cand.Votes.String()) + } + // unendorse bucket + es, err = addOneTx(action.SignedCandidateEndorsement(5, endorseBucketIndex, false, gasLimit, gasPrice, voter1PriKey)) + require.NoError(err) + esHash, err = es.Hash() + require.NoError(err) + r1, err = dao.GetReceiptByActionHash(esHash, bc.TipHeight()) + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, r1.Status) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: bc.TipHeight() + 1, + }) + cands, err = stkPrtcl.ActiveCandidates(ctx, sf, 0) + require.NoError(err) + require.Equal(5, len(cands)) + t.Run("endorsement is withdrawing, candidate can also be chosen as delegate", func(t *testing.T) { + ctx, err = bc.Context(ctx) + require.NoError(err) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: bc.TipHeight(), + }) + ctx = protocol.WithFeatureCtx(ctx) + cands, err = stkPrtcl.ActiveCandidates(ctx, sf, 0) + require.NoError(err) + require.Equal(5, len(cands)) + }) + t.Run("endorsement is expired, candidate can not be chosen as delegate any more", func(t *testing.T) { + ctx, err = bc.Context(ctx) + require.NoError(err) + jumpBlocks(bc, int(cfg.Genesis.EndorsementWithdrawWaitingBlocks), require) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: bc.TipHeight(), + }) + ctx = protocol.WithFeatureCtx(ctx) + cands, err = stkPrtcl.ActiveCandidates(ctx, sf, 0) + require.NoError(err) + require.Equal(4, len(cands)) + }) } cfg := config.Default @@ -427,6 +537,8 @@ func TestNativeStaking(t *testing.T) { cfg.Chain.EnableAsyncIndexWrite = false cfg.Genesis.BootstrapCandidates = testInitCands cfg.Genesis.FbkMigrationBlockHeight = 1 + cfg.Genesis.ToBeEnabledBlockHeight = 0 + cfg.Genesis.EndorsementWithdrawWaitingBlocks = 10 t.Run("test native staking", func(t *testing.T) { testNativeStaking(cfg, t)