From 0ab3c688085da4e15f3d08d4aaf406a553d73a46 Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Fri, 3 Apr 2026 19:28:25 +0300 Subject: [PATCH 1/5] Add ACP-236 auto-renewed validator tx proposal execution --- .../txs/executor/proposal_tx_executor.go | 484 ++++++++++++++++-- .../txs/executor/staker_tx_verification.go | 44 ++ 2 files changed, 479 insertions(+), 49 deletions(-) diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index 269915ebd199..c0c782b3e857 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -8,11 +8,11 @@ import ( "fmt" "time" - "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/components/verify" + "github.com/ava-labs/avalanchego/vms/platformvm/fx" "github.com/ava-labs/avalanchego/vms/platformvm/reward" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/txs" @@ -32,14 +32,17 @@ const ( var ( _ txs.Visitor = (*proposalTxExecutor)(nil) - ErrRemoveStakerTooEarly = errors.New("attempting to remove staker before their end time") - ErrRemoveWrongStaker = errors.New("attempting to remove wrong staker") - ErrInvalidState = errors.New("generated output isn't valid state") - ErrShouldBePermissionlessStaker = errors.New("expected permissionless staker") - ErrWrongTxType = errors.New("wrong transaction type") - ErrInvalidID = errors.New("invalid ID") - ErrProposedAddStakerTxAfterBanff = errors.New("staker transaction proposed after Banff") - ErrAdvanceTimeTxIssuedAfterBanff = errors.New("AdvanceTimeTx issued after Banff") + ErrRemoveStakerTooEarly = errors.New("attempting to remove staker before their end time") + ErrRemoveWrongStaker = errors.New("attempting to remove wrong staker") + ErrInvalidState = errors.New("generated output isn't valid state") + ErrShouldBePermissionlessStaker = errors.New("expected permissionless staker") + ErrWrongTxType = errors.New("wrong transaction type") + ErrInvalidID = errors.New("invalid ID") + ErrProposedAddStakerTxAfterBanff = errors.New("staker transaction proposed after Banff") + ErrAdvanceTimeTxIssuedAfterBanff = errors.New("AdvanceTimeTx issued after Banff") + errShouldBeAutoRenewedStaker = errors.New("expected auto renewed staker") + errShouldUseRewardAutoRenewedValidator = errors.New("auto-renewed validators must be rewarded with RewardAutoRenewedValidatorTx") + errInvalidTimestamp = errors.New("invalid timestamp") ) // ProposalTx executes the proposal transaction [tx]. @@ -342,50 +345,21 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error return txs.ErrNilTx case tx.TxID == ids.Empty: return ErrInvalidID - case len(e.tx.Creds) != 0: - return errWrongNumberOfCredentials } - currentStakerIterator, err := e.onCommitState.GetCurrentStakerIterator() + stakerTx, stakerToReward, err := verifyRewardTx(e.onCommitState, e.tx, tx) if err != nil { return err } - if !currentStakerIterator.Next() { - return fmt.Errorf("failed to get next staker to remove: %w", database.ErrNotFound) - } - stakerToReward := currentStakerIterator.Value() - currentStakerIterator.Release() - - if stakerToReward.TxID != tx.TxID { - return fmt.Errorf( - "%w: %s != %s", - ErrRemoveWrongStaker, - stakerToReward.TxID, - tx.TxID, - ) - } - - // Verify that the chain's timestamp is the validator's end time - currentChainTime := e.onCommitState.GetTimestamp() - if !stakerToReward.EndTime.Equal(currentChainTime) { - return fmt.Errorf( - "%w: TxID = %s with %s < %s", - ErrRemoveStakerTooEarly, - tx.TxID, - currentChainTime, - stakerToReward.EndTime, - ) - } - - stakerTx, _, err := e.onCommitState.GetTx(stakerToReward.TxID) - if err != nil { - return fmt.Errorf("failed to get next removed staker tx: %w", err) - } // Invariant: A [txs.DelegatorTx] does not also implement the // [txs.ValidatorTx] interface. switch uStakerTx := stakerTx.Unsigned.(type) { case txs.ValidatorTx: + if _, ok := uStakerTx.(*txs.AddAutoRenewedValidatorTx); ok { + return errShouldUseRewardAutoRenewedValidator + } + if err := e.rewardValidatorTx(uStakerTx, stakerToReward); err != nil { return err } @@ -419,6 +393,76 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error return ErrShouldBePermissionlessStaker } + return e.decreaseAbortStateCurrentSupply(stakerToReward) +} + +func (e *proposalTxExecutor) RewardAutoRenewedValidatorTx(tx *txs.RewardAutoRenewedValidatorTx) error { + if err := e.tx.SyntacticVerify(e.backend.Ctx); err != nil { + return err + } + + if tx.Timestamp != uint64(e.onCommitState.GetTimestamp().Unix()) { + return errInvalidTimestamp + } + + stakerTx, stakerToReward, err := verifyRewardTx(e.onCommitState, e.tx, tx) + if err != nil { + return err + } + + addAutoRenewedValidatorTx, ok := stakerTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + if !ok { + return errShouldBeAutoRenewedStaker + } + + stakingInfo, err := e.onCommitState.GetStakingInfo(stakerToReward.SubnetID, stakerToReward.NodeID) + if err != nil { + return fmt.Errorf("failed to get staking info: %w", err) + } + + // In onAbortState the staker is always removed. + if err := e.onAbortState.DeleteCurrentValidator(stakerToReward); err != nil { + return fmt.Errorf("deleting current validator from abort state: %w", err) + } + + if err := e.decreaseAbortStateCurrentSupply(stakerToReward); err != nil { + return err + } + + if stakingInfo.Period > 0 { + // Running auto-renewed staker: validator will continue to the next cycle. + // On commit: restake rewards (based on AutoCompoundRewardShares) and start new cycle. + // On abort: return stake + accrued rewards, forfeit current cycle's rewards. + + // Create UTXOs for onAbortState. + if err = e.createUTXOsAutoRenewedValidatorOnAbort(addAutoRenewedValidatorTx, stakerToReward, stakingInfo); err != nil { + return fmt.Errorf("failed to create UTXO auto-renewed validator on abort: %w", err) + } + + // Set onCommitState. + if err = e.setOnCommitStateAutoRenewedValidatorRestake(addAutoRenewedValidatorTx, stakerToReward, stakingInfo); err != nil { + return err + } + + // Early return because we don't need to do anything else. + return nil + } + + // Graceful exit: validator requested to stop (Period == 0). + // Return stake + all rewards on both commit and abort paths. + if err := e.createUTXOsAutoRenewedValidatorOnGracefulExit(addAutoRenewedValidatorTx, stakerToReward, stakingInfo); err != nil { + return err + } + + // Handle staker lifecycle. + if err := e.onCommitState.DeleteCurrentValidator(stakerToReward); err != nil { + return fmt.Errorf("deleting current validator from commit state: %w", err) + } + + return nil +} + +func (e *proposalTxExecutor) decreaseAbortStateCurrentSupply(stakerToReward *state.Staker) error { // If the reward is aborted, then the current supply should be decreased. currentSupply, err := e.onAbortState.GetCurrentSupply(stakerToReward.SubnetID) if err != nil { @@ -432,11 +476,6 @@ func (e *proposalTxExecutor) RewardValidatorTx(tx *txs.RewardValidatorTx) error return nil } -func (*proposalTxExecutor) RewardAutoRenewedValidatorTx(*txs.RewardAutoRenewedValidatorTx) error { - // todo: implement - return nil -} - func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, validator *state.Staker) error { var ( txID = validator.TxID @@ -505,7 +544,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val } delegationRewardsOwner := uValidatorTx.DelegationRewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(delegateeReward, delegationRewardsOwner) + outIntf, err := e.backend.Fx.CreateOutput(stakingInfo.DelegateeReward, delegationRewardsOwner) if err != nil { return fmt.Errorf("failed to create output: %w", err) } @@ -672,3 +711,350 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del } return nil } + +// createUTXOsAutoRenewedValidatorOnAbort creates UTXOs for an auto-renewed validator +// that failed to meet eligibility requirements. +// +// The validator receives: +// - Staked tokens (returned via stake outputs) +// - Accrued validation rewards (from previous successful periods) +// - Accrued delegatee rewards + pending delegatee rewards +// +// The validator forfeits potential reward of the ended cycle. +func (e *proposalTxExecutor) createUTXOsAutoRenewedValidatorOnAbort( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validator *state.Staker, + stakingInfo state.StakingInfo, +) error { + createUTXOsStakeOut(addAutoRenewedValidatorTx, validator, e.onAbortState) + return e.createAbortRewardUTXOs(addAutoRenewedValidatorTx, stakingInfo) +} + +// createAbortRewardUTXOs creates reward UTXOs on the abort state for an +// auto-renewed validator. This includes accrued validation rewards and +// all delegatee rewards (accrued + pending). +func (e *proposalTxExecutor) createAbortRewardUTXOs( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + stakingInfo state.StakingInfo, +) error { + totalDelegateeRewards, err := math.Add(stakingInfo.DelegateeReward, stakingInfo.AccruedDelegateeRewards) + if err != nil { + return err + } + + if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, stakingInfo.AccruedRewards, totalDelegateeRewards, e.onAbortState, uint32(len(e.tx.Unsigned.Outputs()))); err != nil { + return err + } + + return nil +} + +func (e *proposalTxExecutor) createRewardsUTXOs( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validationRewards uint64, + delegateeRewards uint64, + chainState state.Diff, + outputIndexOffset uint32, +) (uint32, error) { + // Create UTXOs for validation rewards. + if validationRewards > 0 { + utxo, err := e.newUTXO(validationRewards, addAutoRenewedValidatorTx.ValidationRewardsOwner(), e.tx.ID(), outputIndexOffset) + if err != nil { + return 0, err + } + chainState.AddUTXO(utxo) + chainState.AddRewardUTXO(e.tx.ID(), utxo) + outputIndexOffset++ + } + + // Create UTXOs for delegatee rewards. + if delegateeRewards > 0 { + utxo, err := e.newUTXO(delegateeRewards, addAutoRenewedValidatorTx.DelegationRewardsOwner(), e.tx.ID(), outputIndexOffset) + if err != nil { + return 0, err + } + chainState.AddUTXO(utxo) + chainState.AddRewardUTXO(e.tx.ID(), utxo) + outputIndexOffset++ + } + + return outputIndexOffset, nil +} + +// setOnCommitStateAutoRenewedValidatorRestake processes rewards for a running +// auto-renewed validator based on their AutoCompoundRewardShares configuration. +// +// The function: +// 1. Splits rewards (validation + delegatee) into restaking and withdrawing portions +// 2. Creates UTXOs for the withdrawn portion +// 3. Increases validator weight and accrued rewards by the restaking portion +// 4. If restaking would exceed MaxValidatorStake, the excess is withdrawn +// 5. Updates the validator state +func (e *proposalTxExecutor) setOnCommitStateAutoRenewedValidatorRestake( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validator *state.Staker, + stakingInfo state.StakingInfo, +) error { + restakingRewards, withdrawingRewards := reward.Split(validator.PotentialReward, stakingInfo.AutoCompoundRewardShares) + restakingDelegateeRewards, withdrawingDelegateeRewards := reward.Split(stakingInfo.DelegateeReward, stakingInfo.AutoCompoundRewardShares) + + outputIndexOffset, err := e.createRewardsUTXOs(addAutoRenewedValidatorTx, withdrawingRewards, withdrawingDelegateeRewards, e.onCommitState, uint32(len(e.tx.Unsigned.Outputs()))) + if err != nil { + return err + } + + newAccruedRewards := stakingInfo.AccruedRewards + newWeight := validator.Weight + if restakingRewards > 0 { + newAccruedRewards, err = math.Add(stakingInfo.AccruedRewards, restakingRewards) + if err != nil { + return err + } + + newWeight, err = math.Add(validator.Weight, restakingRewards) + if err != nil { + return err + } + } + + newAccruedDelegateeRewards := stakingInfo.AccruedDelegateeRewards + if restakingDelegateeRewards > 0 { + newAccruedDelegateeRewards, err = math.Add(stakingInfo.AccruedDelegateeRewards, restakingDelegateeRewards) + if err != nil { + return err + } + + newWeight, err = math.Add(newWeight, restakingDelegateeRewards) + if err != nil { + return err + } + } + + if newWeight > e.backend.Config.MaxValidatorStake { + excessValidationRewards, excessDelegateeRewards, err := e.createOverflowUTXOs(addAutoRenewedValidatorTx, newWeight, restakingDelegateeRewards, restakingRewards, validator.Weight, outputIndexOffset) + if err != nil { + return err + } + + newAccruedRewards, err = math.Sub(newAccruedRewards, excessValidationRewards) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessValidationRewards) + if err != nil { + return err + } + + if excessDelegateeRewards > 0 { + newAccruedDelegateeRewards, err = math.Sub(newAccruedDelegateeRewards, excessDelegateeRewards) + if err != nil { + return err + } + + newWeight, err = math.Sub(newWeight, excessDelegateeRewards) + if err != nil { + return err + } + } + + // newWeight is equal to e.backend.Config.MaxValidatorStake. + } + + rewards, err := GetRewardsCalculator(e.backend, e.onCommitState, validator.SubnetID) + if err != nil { + return err + } + + currentSupply, err := e.onCommitState.GetCurrentSupply(validator.SubnetID) + if err != nil { + return err + } + + potentialReward := rewards.Calculate( + stakingInfo.Period, + newWeight, + currentSupply, + ) + + newCurrentSupply, err := math.Add(currentSupply, potentialReward) + if err != nil { + return err + } + + e.onCommitState.SetCurrentSupply(validator.SubnetID, newCurrentSupply) + + endTime := validator.EndTime.Add(stakingInfo.Period) + + // Update validator by deleting and putting back. + renewedValidator := *validator + renewedValidator.StartTime = renewedValidator.EndTime + renewedValidator.EndTime = endTime + renewedValidator.NextTime = endTime + renewedValidator.PotentialReward = potentialReward + renewedValidator.Weight = newWeight + + if err := e.onCommitState.DeleteCurrentValidator(validator); err != nil { + return fmt.Errorf("failed to delete validator from commit state: %w", err) + } + + if err := e.onCommitState.PutCurrentValidator(&renewedValidator); err != nil { + return fmt.Errorf("putting renewed validator: %w", err) + } + + // Update staking info + stakingInfo.DelegateeReward = 0 + stakingInfo.AccruedRewards = newAccruedRewards + stakingInfo.AccruedDelegateeRewards = newAccruedDelegateeRewards + + if err := e.onCommitState.SetStakingInfo(validator.SubnetID, validator.NodeID, stakingInfo); err != nil { + return fmt.Errorf("setting staking info for validator: %w", err) + } + + return nil +} + +// createUTXOsAutoRenewedValidatorOnGracefulExit creates UTXOs for an auto-renewed +// validator that is stopping gracefully. +// +// On commit (validator eligible for rewards): +// - Staked tokens (returned via stake outputs) +// - All validation rewards (PotentialReward + AccruedRewards) +// - All delegatee rewards (AccruedDelegateeRewards + pending delegatee rewards) +// +// On abort (validator not eligible for rewards): +// - Staked tokens (returned via stake outputs) +// - Only accrued validation rewards (AccruedRewards) +// - All delegatee rewards (AccruedDelegateeRewards + pending delegatee rewards) +func (e *proposalTxExecutor) createUTXOsAutoRenewedValidatorOnGracefulExit( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + validator *state.Staker, + stakingInfo state.StakingInfo, +) error { + createUTXOsStakeOut(addAutoRenewedValidatorTx, validator, e.onCommitState, e.onAbortState) + + if err := e.createAbortRewardUTXOs(addAutoRenewedValidatorTx, stakingInfo); err != nil { + return err + } + + totalRewards, err := math.Add(validator.PotentialReward, stakingInfo.AccruedRewards) + if err != nil { + return err + } + + totalDelegateeRewards, err := math.Add(stakingInfo.DelegateeReward, stakingInfo.AccruedDelegateeRewards) + if err != nil { + return err + } + + if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, totalRewards, totalDelegateeRewards, e.onCommitState, uint32(len(e.tx.Unsigned.Outputs()))); err != nil { + return err + } + + return nil +} + +// newUTXO creates a reward UTXO with the specified parameters. +// The caller is responsible for adding the UTXO to the appropriate state(s) +// via AddUTXO and AddRewardUTXO. +func (e *proposalTxExecutor) newUTXO( + amount uint64, + owner fx.Owner, + txID ids.ID, + outputIndex uint32, +) (*avax.UTXO, error) { + outIntf, err := e.backend.Fx.CreateOutput(amount, owner) + if err != nil { + return nil, fmt.Errorf("failed to create output: %w", err) + } + out, ok := outIntf.(verify.State) + if !ok { + return nil, ErrInvalidState + } + + return &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: txID, + OutputIndex: outputIndex, + }, + Asset: avax.Asset{ID: e.backend.Ctx.AVAXAssetID}, + Out: out, + }, nil +} + +// createOverflowUTXOs creates UTXOs for the excess rewards +// that cannot be restaked because doing so would exceed MaxValidatorStake. +// +// When an auto-renewed validator's restaked rewards would push their weight above +// MaxValidatorStake, the excess is withdrawn. +// +// Returns the excess validation and delegatee rewards that were withdrawn. +func (e *proposalTxExecutor) createOverflowUTXOs( + addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, + newWeight uint64, + delegateeReward uint64, + rewards uint64, + oldWeight uint64, + outputIndexOffset uint32, +) (excessValidationRewards uint64, excessDelegateeRewards uint64, err error) { + totalRestakingRewards, err := math.Sub(newWeight, oldWeight) + if err != nil { + return 0, 0, err + } + + // Calculate how much room we have to grow before hitting max stake. + restakingAvailability, err := math.Sub(e.backend.Config.MaxValidatorStake, oldWeight) + if err != nil { + return 0, 0, err + } + + // Distribute available space proportionally between validation and delegatee rewards. + restakingValidationReward, err := math.MulDiv(rewards, restakingAvailability, totalRestakingRewards) + if err != nil { + return 0, 0, err + } + + restakingDelegateeReward, err := math.Sub(restakingAvailability, restakingValidationReward) + if err != nil { + return 0, 0, err + } + + // rewards >= restakingValidationReward, but using math package as a defensive check. + excessValidationReward, err := math.Sub(rewards, restakingValidationReward) + if err != nil { + return 0, 0, err + } + + // delegateeReward >= restakingDelegateeReward, but using math package as a defensive check. + excessDelegateeReward, err := math.Sub(delegateeReward, restakingDelegateeReward) + if err != nil { + return 0, 0, err + } + + if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, excessValidationReward, excessDelegateeReward, e.onCommitState, outputIndexOffset); err != nil { + return 0, 0, err + } + + return excessValidationReward, excessDelegateeReward, nil +} + +// createUTXOsStakeOut creates UTXOs to return a validator's staked tokens. +// The UTXOs are added to all provided state diffs. +func createUTXOsStakeOut(addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, validator *state.Staker, states ...state.Diff) { + outputIndexOffset := len(addAutoRenewedValidatorTx.Outputs()) + + for i, out := range addAutoRenewedValidatorTx.Stake() { + utxo := &avax.UTXO{ + UTXOID: avax.UTXOID{ + TxID: validator.TxID, + OutputIndex: uint32(outputIndexOffset + i), + }, + Asset: out.Asset, + Out: out.Output(), + } + + for _, s := range states { + s.AddUTXO(utxo) + } + } +} diff --git a/vms/platformvm/txs/executor/staker_tx_verification.go b/vms/platformvm/txs/executor/staker_tx_verification.go index 8c9eb6a01842..37cd4b181793 100644 --- a/vms/platformvm/txs/executor/staker_tx_verification.go +++ b/vms/platformvm/txs/executor/staker_tx_verification.go @@ -1088,3 +1088,47 @@ func verifySpend( return nil } + +func verifyRewardTx(chainState state.Chain, sTx *txs.Tx, tx txs.RewardTx) (*txs.Tx, *state.Staker, error) { + if len(sTx.Creds) != 0 { + return nil, nil, errWrongNumberOfCredentials + } + + currentStakerIterator, err := chainState.GetCurrentStakerIterator() + if err != nil { + return nil, nil, err + } + if !currentStakerIterator.Next() { + return nil, nil, fmt.Errorf("failed to get next staker to remove: %w", database.ErrNotFound) + } + stakerToReward := currentStakerIterator.Value() + currentStakerIterator.Release() + + if stakerToReward.TxID != tx.StakerTxID() { + return nil, nil, fmt.Errorf( + "%w: %s != %s", + ErrRemoveWrongStaker, + stakerToReward.TxID, + tx.StakerTxID(), + ) + } + + // Verify that the chain's timestamp is the validator's end time + currentChainTime := chainState.GetTimestamp() + if !stakerToReward.EndTime.Equal(currentChainTime) { + return nil, nil, fmt.Errorf( + "%w: TxID = %s with %s < %s", + ErrRemoveStakerTooEarly, + tx.StakerTxID(), + currentChainTime, + stakerToReward.EndTime, + ) + } + + stakerTx, _, err := chainState.GetTx(stakerToReward.TxID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get next removed staker tx: %w", err) + } + + return stakerTx, stakerToReward, nil +} From cf27e433e52c14b451bd37cdadbfb59082316ac0 Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Wed, 29 Apr 2026 12:57:14 +0300 Subject: [PATCH 2/5] Rollback unnecessary var replacement --- vms/platformvm/txs/executor/proposal_tx_executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index c0c782b3e857..b7908d8f045d 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -544,7 +544,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val } delegationRewardsOwner := uValidatorTx.DelegationRewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(stakingInfo.DelegateeReward, delegationRewardsOwner) + outIntf, err := e.backend.Fx.CreateOutput(delegateeReward, delegationRewardsOwner) if err != nil { return fmt.Errorf("failed to create output: %w", err) } From fc126bbde14426d26a4e178b41d748e1079327c2 Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Wed, 29 Apr 2026 19:55:15 +0300 Subject: [PATCH 3/5] Use duration locally for auto-renewed restake --- vms/platformvm/txs/executor/proposal_tx_executor.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index b7908d8f045d..611972bdce75 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -871,8 +871,9 @@ func (e *proposalTxExecutor) setOnCommitStateAutoRenewedValidatorRestake( return err } + duration := time.Duration(stakingInfo.Period) * time.Second potentialReward := rewards.Calculate( - stakingInfo.Period, + duration, newWeight, currentSupply, ) @@ -884,7 +885,7 @@ func (e *proposalTxExecutor) setOnCommitStateAutoRenewedValidatorRestake( e.onCommitState.SetCurrentSupply(validator.SubnetID, newCurrentSupply) - endTime := validator.EndTime.Add(stakingInfo.Period) + endTime := validator.EndTime.Add(duration) // Update validator by deleting and putting back. renewedValidator := *validator From e15de36ae7684afabf2eea8b4bf6caa5283f6c6e Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Thu, 30 Apr 2026 17:45:26 +0300 Subject: [PATCH 4/5] Refactor reward UTXO creation in proposal_tx_executor --- .../txs/executor/proposal_tx_executor.go | 156 ++++++------------ 1 file changed, 55 insertions(+), 101 deletions(-) diff --git a/vms/platformvm/txs/executor/proposal_tx_executor.go b/vms/platformvm/txs/executor/proposal_tx_executor.go index 611972bdce75..47119d24184a 100644 --- a/vms/platformvm/txs/executor/proposal_tx_executor.go +++ b/vms/platformvm/txs/executor/proposal_tx_executor.go @@ -485,43 +485,22 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val stakeAsset = stake[0].Asset ) - // Refund the stake only when validator is about to leave - // the staking set - for i, out := range stake { - utxo := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: txID, - OutputIndex: uint32(len(outputs) + i), - }, - Asset: out.Asset, - Out: out.Output(), - } - e.onCommitState.AddUTXO(utxo) - e.onAbortState.AddUTXO(utxo) - } + createUTXOsStakeOut(uValidatorTx, txID, e.onCommitState, e.onAbortState) utxosOffset := 0 // Provide the reward here reward := validator.PotentialReward if reward > 0 { - validationRewardsOwner := uValidatorTx.ValidationRewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(reward, validationRewardsOwner) + utxo, err := e.newUTXO( + reward, + uValidatorTx.ValidationRewardsOwner(), + txID, + uint32(len(outputs)+len(stake)), + stakeAsset, + ) if err != nil { - return fmt.Errorf("failed to create output: %w", err) - } - out, ok := outIntf.(verify.State) - if !ok { - return ErrInvalidState - } - - utxo := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake)), - }, - Asset: stakeAsset, - Out: out, + return err } e.onCommitState.AddUTXO(utxo) e.onCommitState.AddRewardUTXO(txID, utxo) @@ -544,22 +523,15 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val } delegationRewardsOwner := uValidatorTx.DelegationRewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(delegateeReward, delegationRewardsOwner) + onCommitUtxo, err := e.newUTXO( + delegateeReward, + delegationRewardsOwner, + txID, + uint32(len(outputs)+len(stake)+utxosOffset), + stakeAsset, + ) if err != nil { - return fmt.Errorf("failed to create output: %w", err) - } - out, ok := outIntf.(verify.State) - if !ok { - return ErrInvalidState - } - - onCommitUtxo := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake) + utxosOffset), - }, - Asset: stakeAsset, - Out: out, + return err } e.onCommitState.AddUTXO(onCommitUtxo) e.onCommitState.AddRewardUTXO(txID, onCommitUtxo) @@ -572,7 +544,7 @@ func (e *proposalTxExecutor) rewardValidatorTx(uValidatorTx txs.ValidatorTx, val OutputIndex: uint32(len(outputs) + len(stake)), }, Asset: stakeAsset, - Out: out, + Out: onCommitUtxo.Out, } e.onAbortState.AddUTXO(onAbortUtxo) e.onAbortState.AddRewardUTXO(txID, onAbortUtxo) @@ -588,20 +560,7 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del stakeAsset = stake[0].Asset ) - // Refund the stake only when delegator is about to leave - // the staking set - for i, out := range stake { - utxo := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: txID, - OutputIndex: uint32(len(outputs) + i), - }, - Asset: out.Asset, - Out: out.Output(), - } - e.onCommitState.AddUTXO(utxo) - e.onAbortState.AddUTXO(utxo) - } + createUTXOsStakeOut(uDelegatorTx, txID, e.onCommitState, e.onAbortState) // We're (possibly) rewarding a delegator, so we need to fetch // the validator they are delegated to. @@ -632,22 +591,15 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del // Reward the delegator here reward := delegatorReward if reward > 0 { - rewardsOwner := uDelegatorTx.RewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(reward, rewardsOwner) + utxo, err := e.newUTXO( + reward, + uDelegatorTx.RewardsOwner(), + txID, + uint32(len(outputs)+len(stake)), + stakeAsset, + ) if err != nil { - return fmt.Errorf("failed to create output: %w", err) - } - out, ok := outIntf.(verify.State) - if !ok { - return ErrInvalidState - } - utxo := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake)), - }, - Asset: stakeAsset, - Out: out, + return err } e.onCommitState.AddUTXO(utxo) @@ -688,22 +640,15 @@ func (e *proposalTxExecutor) rewardDelegatorTx(uDelegatorTx txs.DelegatorTx, del } else { // For any validators who started prior to [CortinaTime], we issue the // [delegateeReward] immediately. - delegationRewardsOwner := vdrTx.DelegationRewardsOwner() - outIntf, err := e.backend.Fx.CreateOutput(delegateeReward, delegationRewardsOwner) + utxo, err := e.newUTXO( + delegateeReward, + vdrTx.DelegationRewardsOwner(), + txID, + uint32(len(outputs)+len(stake)+utxosOffset), + stakeAsset, + ) if err != nil { - return fmt.Errorf("failed to create output: %w", err) - } - out, ok := outIntf.(verify.State) - if !ok { - return ErrInvalidState - } - utxo := &avax.UTXO{ - UTXOID: avax.UTXOID{ - TxID: txID, - OutputIndex: uint32(len(outputs) + len(stake) + utxosOffset), - }, - Asset: stakeAsset, - Out: out, + return err } e.onCommitState.AddUTXO(utxo) @@ -726,7 +671,7 @@ func (e *proposalTxExecutor) createUTXOsAutoRenewedValidatorOnAbort( validator *state.Staker, stakingInfo state.StakingInfo, ) error { - createUTXOsStakeOut(addAutoRenewedValidatorTx, validator, e.onAbortState) + createUTXOsStakeOut(addAutoRenewedValidatorTx, validator.TxID, e.onAbortState) return e.createAbortRewardUTXOs(addAutoRenewedValidatorTx, stakingInfo) } @@ -742,7 +687,13 @@ func (e *proposalTxExecutor) createAbortRewardUTXOs( return err } - if _, err = e.createRewardsUTXOs(addAutoRenewedValidatorTx, stakingInfo.AccruedRewards, totalDelegateeRewards, e.onAbortState, uint32(len(e.tx.Unsigned.Outputs()))); err != nil { + if _, err = e.createRewardsUTXOs( + addAutoRenewedValidatorTx, + stakingInfo.AccruedRewards, + totalDelegateeRewards, + e.onAbortState, + uint32(len(e.tx.Unsigned.Outputs())), + ); err != nil { return err } @@ -756,9 +707,11 @@ func (e *proposalTxExecutor) createRewardsUTXOs( chainState state.Diff, outputIndexOffset uint32, ) (uint32, error) { + avaxAsset := avax.Asset{ID: e.backend.Ctx.AVAXAssetID} + // Create UTXOs for validation rewards. if validationRewards > 0 { - utxo, err := e.newUTXO(validationRewards, addAutoRenewedValidatorTx.ValidationRewardsOwner(), e.tx.ID(), outputIndexOffset) + utxo, err := e.newUTXO(validationRewards, addAutoRenewedValidatorTx.ValidationRewardsOwner(), e.tx.ID(), outputIndexOffset, avaxAsset) if err != nil { return 0, err } @@ -769,7 +722,7 @@ func (e *proposalTxExecutor) createRewardsUTXOs( // Create UTXOs for delegatee rewards. if delegateeRewards > 0 { - utxo, err := e.newUTXO(delegateeRewards, addAutoRenewedValidatorTx.DelegationRewardsOwner(), e.tx.ID(), outputIndexOffset) + utxo, err := e.newUTXO(delegateeRewards, addAutoRenewedValidatorTx.DelegationRewardsOwner(), e.tx.ID(), outputIndexOffset, avaxAsset) if err != nil { return 0, err } @@ -932,7 +885,7 @@ func (e *proposalTxExecutor) createUTXOsAutoRenewedValidatorOnGracefulExit( validator *state.Staker, stakingInfo state.StakingInfo, ) error { - createUTXOsStakeOut(addAutoRenewedValidatorTx, validator, e.onCommitState, e.onAbortState) + createUTXOsStakeOut(addAutoRenewedValidatorTx, validator.TxID, e.onCommitState, e.onAbortState) if err := e.createAbortRewardUTXOs(addAutoRenewedValidatorTx, stakingInfo); err != nil { return err @@ -963,6 +916,7 @@ func (e *proposalTxExecutor) newUTXO( owner fx.Owner, txID ids.ID, outputIndex uint32, + asset avax.Asset, ) (*avax.UTXO, error) { outIntf, err := e.backend.Fx.CreateOutput(amount, owner) if err != nil { @@ -978,7 +932,7 @@ func (e *proposalTxExecutor) newUTXO( TxID: txID, OutputIndex: outputIndex, }, - Asset: avax.Asset{ID: e.backend.Ctx.AVAXAssetID}, + Asset: asset, Out: out, }, nil } @@ -1039,15 +993,15 @@ func (e *proposalTxExecutor) createOverflowUTXOs( return excessValidationReward, excessDelegateeReward, nil } -// createUTXOsStakeOut creates UTXOs to return a validator's staked tokens. +// createUTXOsStakeOut creates UTXOs to return a staker's staked tokens. // The UTXOs are added to all provided state diffs. -func createUTXOsStakeOut(addAutoRenewedValidatorTx *txs.AddAutoRenewedValidatorTx, validator *state.Staker, states ...state.Diff) { - outputIndexOffset := len(addAutoRenewedValidatorTx.Outputs()) +func createUTXOsStakeOut(stakerTx txs.PermissionlessStaker, txID ids.ID, states ...state.Diff) { + outputIndexOffset := len(stakerTx.Outputs()) - for i, out := range addAutoRenewedValidatorTx.Stake() { + for i, out := range stakerTx.Stake() { utxo := &avax.UTXO{ UTXOID: avax.UTXOID{ - TxID: validator.TxID, + TxID: txID, OutputIndex: uint32(outputIndexOffset + i), }, Asset: out.Asset, From d6b739300c103006c09b97e484cf00f91c14eb53 Mon Sep 17 00:00:00 2001 From: "razvan.angheluta" Date: Tue, 5 May 2026 14:50:21 +0300 Subject: [PATCH 5/5] Add auto-renewed RewardValidatorTx rejection test --- .../txs/executor/reward_validator_test.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/vms/platformvm/txs/executor/reward_validator_test.go b/vms/platformvm/txs/executor/reward_validator_test.go index 1ee5b32f3023..4b3378170523 100644 --- a/vms/platformvm/txs/executor/reward_validator_test.go +++ b/vms/platformvm/txs/executor/reward_validator_test.go @@ -14,11 +14,13 @@ import ( "github.com/ava-labs/avalanchego/snow/snowtest" "github.com/ava-labs/avalanchego/upgrade/upgradetest" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/utils/crypto/bls/signer/localsigner" "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/platformvm/genesis/genesistest" "github.com/ava-labs/avalanchego/vms/platformvm/reward" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" "github.com/ava-labs/avalanchego/vms/platformvm/state" "github.com/ava-labs/avalanchego/vms/platformvm/status" "github.com/ava-labs/avalanchego/vms/platformvm/txs" @@ -887,3 +889,58 @@ func TestRewardDelegatorTxExecuteOnAbort(t *testing.T) { require.NoError(err) require.Equal(initialSupply-expectedReward, newSupply, "should have removed un-rewarded tokens from the potential supply") } + +func TestRewardValidatorStakerType(t *testing.T) { + var ( + env = newEnvironment(t, upgradetest.Fortuna) + feeCalculator = state.PickFeeCalculator(env.config, env.state) + wallet = newWallet(t, env, walletConfig{}) + ) + + addAutoRenewedValidatorTx, err := wallet.IssueAddAutoRenewedValidatorTx( + ids.GenerateTestNodeID(), + env.config.MinValidatorStake, + must[*signer.ProofOfPossession](t)(signer.NewProofOfPossession(must[*localsigner.LocalSigner](t)(localsigner.New()))), + env.ctx.AVAXAssetID, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + &secp256k1fx.OutputOwners{}, + reward.PercentDenominator, + reward.PercentDenominator, + uint64(env.config.MinStakeDuration/time.Second), + ) + require.NoError(t, err) + env.state.AddTx(addAutoRenewedValidatorTx, status.Committed) + + validatorTx := addAutoRenewedValidatorTx.Unsigned.(*txs.AddAutoRenewedValidatorTx) + + startTime := time.Unix(int64(genesistest.DefaultValidatorStartTimeUnix+1), 0) + duration := time.Duration(validatorTx.Period) * time.Second + vdrStaker, err := state.NewStaker( + addAutoRenewedValidatorTx.ID(), + validatorTx, + startTime, + startTime.Add(duration), + validatorTx.Weight(), + uint64(1000000), + ) + require.NoError(t, err) + + require.NoError(t, env.state.PutCurrentValidator(vdrStaker)) + require.NoError(t, env.state.Commit()) + + require.NoError(t, env.state.SetStakingInfo(vdrStaker.SubnetID, vdrStaker.NodeID, state.StakingInfo{ + Period: validatorTx.Period, + })) + + env.state.SetTimestamp(vdrStaker.EndTime) + + err = ProposalTx( + &env.backend, + feeCalculator, + must[*txs.Tx](t)(newRewardValidatorTx(t, addAutoRenewedValidatorTx.ID())), + must[state.Diff](t)(state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed)), // onCommitState + must[state.Diff](t)(state.NewDiffOn(env.state, state.StakerAdditionAfterDeletionAllowed)), // onAbortState + ) + require.ErrorIs(t, err, errShouldUseRewardAutoRenewedValidator) +}