From 79c7b8348c6f4e3c886b459ee26561aa55698f66 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Mon, 29 Dec 2025 18:15:30 +0800 Subject: [PATCH 01/23] initial version of staking v2 --- core/staking_verifier.go | 68 ++++++++++++++++++++++++++++++++------- internal/params/config.go | 17 ++++++++++ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/core/staking_verifier.go b/core/staking_verifier.go index 5b644d3908..be04022486 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -279,24 +279,68 @@ func VerifyAndDelegateFromMsg( startBalance := big.NewInt(0).Set(delegateBalance) // Start from the oldest undelegated tokens curIndex := 0 - for ; curIndex < len(delegation.Undelegations); curIndex++ { - if delegation.Undelegations[curIndex].Epoch.Cmp(epoch) >= 0 { - break + isStakingV2 := chainConfig.IsStakingV2(epoch) + + if isStakingV2 { + // Staking V2: Properly handle undelegation consumption with explicit entry removal + newUndelegations := []staking.Undelegation{} + for curIndex < len(delegation.Undelegations) { + entry := &delegation.Undelegations[curIndex] + if entry.Epoch.Cmp(epoch) >= 0 { + // Keep all remaining entries (not yet eligible for redelegation) + newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) + break + } + + if entry.Amount.Cmp(delegateBalance) <= 0 { + // Fully consume this entry + delegateBalance.Sub(delegateBalance, entry.Amount) + // Don't add to newUndelegations (fully consumed) + } else { + // Partially consume this entry + remainingAmount := big.NewInt(0).Sub(entry.Amount, delegateBalance) + newUndelegations = append(newUndelegations, staking.Undelegation{ + Amount: remainingAmount, + Epoch: entry.Epoch, + }) + delegateBalance = big.NewInt(0) + curIndex++ + // Keep all remaining entries + if curIndex < len(delegation.Undelegations) { + newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) + } + break + } + curIndex++ } - if delegation.Undelegations[curIndex].Amount.Cmp(delegateBalance) <= 0 { - delegateBalance.Sub(delegateBalance, delegation.Undelegations[curIndex].Amount) - } else { - delegation.Undelegations[curIndex].Amount.Sub( - delegation.Undelegations[curIndex].Amount, delegateBalance, - ) - delegateBalance = big.NewInt(0) - break + // Only update undelegations if something was consumed + if startBalance.Cmp(delegateBalance) > 0 { + delegation.Undelegations = newUndelegations + } + } else { + // Original logic (for backward compatibility) + for ; curIndex < len(delegation.Undelegations); curIndex++ { + if delegation.Undelegations[curIndex].Epoch.Cmp(epoch) >= 0 { + break + } + if delegation.Undelegations[curIndex].Amount.Cmp(delegateBalance) <= 0 { + delegateBalance.Sub(delegateBalance, delegation.Undelegations[curIndex].Amount) + } else { + delegation.Undelegations[curIndex].Amount.Sub( + delegation.Undelegations[curIndex].Amount, delegateBalance, + ) + delegateBalance = big.NewInt(0) + break + } } } if startBalance.Cmp(delegateBalance) > 0 { // Used undelegated token for redelegation - delegation.Undelegations = delegation.Undelegations[curIndex:] + if !isStakingV2 { + // Original logic: slice undelegations array + delegation.Undelegations = delegation.Undelegations[curIndex:] + } if err := wrapper.SanityCheck(); err != nil { return nil, nil, nil, err } diff --git a/internal/params/config.go b/internal/params/config.go index 29935cd1a8..54030b475a 100644 --- a/internal/params/config.go +++ b/internal/params/config.go @@ -83,6 +83,7 @@ var ( HIP32Epoch: big.NewInt(2152), // 2024-10-31 13:02 UTC IsOneSecondEpoch: EpochTBD, EIP2537PrecompileEpoch: EpochTBD, + StakingV2Epoch: EpochTBD, } // TestnetChainConfig contains the chain parameters to run a node on the harmony test network. @@ -134,6 +135,7 @@ var ( IsOneSecondEpoch: EpochTBD, EIP2537PrecompileEpoch: EpochTBD, EIP1153TransientStorageEpoch: big.NewInt(6280), + StakingV2Epoch: EpochTBD, } // PangaeaChainConfig contains the chain parameters for the Pangaea network. // All features except for CrossLink are enabled at launch. @@ -184,6 +186,7 @@ var ( TestnetExternalEpoch: EpochTBD, IsOneSecondEpoch: EpochTBD, EIP2537PrecompileEpoch: EpochTBD, + StakingV2Epoch: EpochTBD, } // PartnerChainConfig contains the chain parameters for the Partner network. @@ -236,6 +239,7 @@ var ( IsOneSecondEpoch: big.NewInt(17436), EIP2537PrecompileEpoch: EpochTBD, EIP1153TransientStorageEpoch: big.NewInt(35626), + StakingV2Epoch: EpochTBD, } // StressnetChainConfig contains the chain parameters for the Stress test network. @@ -287,6 +291,7 @@ var ( TestnetExternalEpoch: EpochTBD, IsOneSecondEpoch: EpochTBD, EIP2537PrecompileEpoch: EpochTBD, + StakingV2Epoch: EpochTBD, } // LocalnetChainConfig contains the chain parameters to run for local development. @@ -337,6 +342,7 @@ var ( TestnetExternalEpoch: EpochTBD, IsOneSecondEpoch: big.NewInt(4), EIP2537PrecompileEpoch: EpochTBD, + StakingV2Epoch: EpochTBD, } // AllProtocolChanges ... @@ -391,6 +397,7 @@ var ( big.NewInt(0), big.NewInt(0), // EIP2537PrecompileEpoch big.NewInt(0), // 1153 transient storage + big.NewInt(0), // StakingV2Epoch } // TestChainConfig ... @@ -445,6 +452,7 @@ var ( big.NewInt(0), big.NewInt(0), // EIP2537PrecompileEpoch big.NewInt(0), // 1153 transient storage + big.NewInt(0), // StakingV2Epoch } // TestRules ... @@ -634,6 +642,10 @@ type ChainConfig struct { // EIP2537PrecompileEpoch is the first epoch to support the EIP-2537 precompiles EIP1153TransientStorageEpoch *big.Int `json:"eip1153-transient-storage-epoch,omitempty"` + + // StakingV2Epoch is the epoch when Staking V2 is activated, which fixes critical + // issues in redelegation logic and other staking operations + StakingV2Epoch *big.Int `json:"staking-v2-epoch,omitempty"` } // String implements the fmt.Stringer interface. @@ -937,6 +949,11 @@ func (c *ChainConfig) IsTopMaxRate(epoch *big.Int) bool { return isForked(c.TopMaxRateEpoch, epoch) } +// IsStakingV2 determines whether it is the epoch when Staking V2 is activated +func (c *ChainConfig) IsStakingV2(epoch *big.Int) bool { + return isForked(c.StakingV2Epoch, epoch) +} + // During this epoch, shards 2 and 3 will start sending // their balances over to shard 0 or 1. func (c *ChainConfig) IsOneEpochBeforeHIP30(epoch *big.Int) bool { From 02b5d2b8759b0837b89696a18a843e1a1eee8116 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Mon, 29 Dec 2025 18:16:01 +0800 Subject: [PATCH 02/23] add tests for staking v2, verify corner cases --- core/staking_verifier_test.go | 339 ++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 53d47a4788..cedc206458 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -1134,6 +1134,35 @@ func makeStateForRedelegate(t *testing.T) *state.DB { return sdb } +// makeStateForRedelegateCornerCases creates state with multiple undelegation entries for corner case testing +func makeStateForRedelegateCornerCases(t *testing.T, validatorAddr common.Address, undelegations []struct { + amount *big.Int + epoch *big.Int +}) *state.DB { + sdb := makeStateDBForStake(t) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, true) + if err != nil { + t.Fatal(err) + } + + // Add delegation with multiple undelegation entries + delegation := staking.NewDelegation(delegatorAddr, new(big.Int).Set(twentyKOnes)) + for _, undel := range undelegations { + if err := delegation.Undelegate(undel.epoch, undel.amount); err != nil { + t.Fatal(err) + } + } + w.Delegations = append(w.Delegations, delegation) + + if err := sdb.UpdateValidatorWrapper(validatorAddr, w); err != nil { + t.Fatal(err) + } + + sdb.IntermediateRoot(true) + return sdb +} + func addStateUndelegationForAddr(sdb *state.DB, addr common.Address, epoch *big.Int) error { w, err := sdb.ValidatorWrapper(addr, false, true) if err != nil { @@ -1838,3 +1867,313 @@ func assertError(got, expect error) error { } return nil } + +// TestRedelegationCornerCases tests corner cases in redelegation logic that demonstrate +// bugs in the old implementation and fixes in Staking V2 +func TestRedelegationCornerCases(t *testing.T) { + epoch := big.NewInt(10) // Current epoch + epoch1 := big.NewInt(5) // Old undelegation epoch + epoch2 := big.NewInt(6) // Old undelegation epoch + epoch3 := big.NewInt(7) // Old undelegation epoch + + tests := []struct { + name string + undelegations []struct { + amount *big.Int + epoch *big.Int + } + delegateAmount *big.Int + stakingV2 bool + expectedUndelegations []struct { + amount *big.Int + epoch *big.Int + } + expectedBalanceDeducted *big.Int + description string + }{ + { + name: "CornerCase1_FullyConsumeFirst_PartiallyConsumeSecond", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch2}, // 10000 - partially consumed (need 5000, so 5000 remains) + {amount: fiveKOnes, epoch: epoch3}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Add(fiveKOnes, fiveKOnes), // 10000 total + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic bug: keeps partially consumed entry but may have issues with slice manipulation + {amount: fiveKOnes, epoch: epoch2}, // Partially consumed (should be 5000) + {amount: fiveKOnes, epoch: epoch3}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), // All from undelegations + description: "Old logic: Fully consume first entry, partially consume second. May have slice manipulation issues.", + }, + { + name: "CornerCase1_FullyConsumeFirst_PartiallyConsumeSecond_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch2}, // 10000 - partially consumed (need 5000, so 5000 remains) + {amount: fiveKOnes, epoch: epoch3}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Add(fiveKOnes, fiveKOnes), // 10000 total + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: correctly removes fully consumed, keeps partially consumed with correct amount + {amount: fiveKOnes, epoch: epoch2}, // Partially consumed (5000 remains) + {amount: fiveKOnes, epoch: epoch3}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), // All from undelegations + description: "New logic: Correctly removes fully consumed entry, keeps partially consumed with correct amount.", + }, + { + name: "CornerCase2_PartiallyConsumeFirst", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: tenKOnes, epoch: epoch1}, // 10000 - partially consumed (need 3000, so 7000 remains) + {amount: fiveKOnes, epoch: epoch2}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Mul(big.NewInt(3000), oneBig), // 3000 ONE (meets minimum) + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic: modifies entry in place, then slices + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(3000), oneBig)), epoch: epoch1}, // 7000 + {amount: fiveKOnes, epoch: epoch2}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), + description: "Old logic: Partially consumes first entry. May work but uses error-prone slice manipulation.", + }, + { + name: "CornerCase2_PartiallyConsumeFirst_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: tenKOnes, epoch: epoch1}, // 10000 - partially consumed (need 3000, so 7000 remains) + {amount: fiveKOnes, epoch: epoch2}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Mul(big.NewInt(3000), oneBig), // 3000 ONE (meets minimum) + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: creates new entry with correct remaining amount + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(3000), oneBig)), epoch: epoch1}, // 7000 + {amount: fiveKOnes, epoch: epoch2}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), + description: "New logic: Explicitly creates new entry with correct remaining amount.", + }, + { + name: "CornerCase3_FullyConsumeAll", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch3}, // 5000 - fully consumed + }, + delegateAmount: new(big.Int).Mul(big.NewInt(15000), oneBig), // 15000 ONE (all three, meets minimum) + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic: should remove all, but may have issues + }, + expectedBalanceDeducted: big.NewInt(0), + description: "Old logic: Fully consumes all entries. Should result in empty undelegations.", + }, + { + name: "CornerCase3_FullyConsumeAll_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch3}, // 5000 - fully consumed + }, + delegateAmount: new(big.Int).Mul(big.NewInt(15000), oneBig), // 15000 ONE (all three, meets minimum) + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: correctly removes all fully consumed entries + }, + expectedBalanceDeducted: big.NewInt(0), + description: "New logic: Correctly removes all fully consumed entries, results in empty undelegations.", + }, + { + name: "CornerCase4_MixedConsumption", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch3}, // 10000 - partially consumed (need 2000, so 8000 remains) + }, + delegateAmount: new(big.Int).Mul(big.NewInt(12000), oneBig), // 12000 ONE (meets minimum) + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic: may have issues with multiple full consumptions followed by partial + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(2000), oneBig)), epoch: epoch3}, // 8000 + }, + expectedBalanceDeducted: big.NewInt(0), + description: "Old logic: Mixed full and partial consumption. May have slice manipulation issues.", + }, + { + name: "CornerCase4_MixedConsumption_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch3}, // 10000 - partially consumed (need 2000, so 8000 remains) + }, + delegateAmount: new(big.Int).Mul(big.NewInt(12000), oneBig), // 12000 ONE (meets minimum) + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: correctly handles mixed consumption + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(2000), oneBig)), epoch: epoch3}, // 8000 + }, + expectedBalanceDeducted: big.NewInt(0), + description: "New logic: Correctly handles mixed full and partial consumption.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create state with undelegations + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, test.undelegations) + + // Create delegate message + msg := staking.Delegate{ + DelegatorAddress: delegatorAddr, + ValidatorAddress: validatorAddr, + Amount: new(big.Int).Set(test.delegateAmount), + } + + // Get delegation index + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{ + { + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), // Last delegation (the one with undelegations) + BlockNum: big.NewInt(100), + }, + } + + // Configure chain config + config := ¶ms.ChainConfig{} + config.MinDelegation100Epoch = big.NewInt(100) + config.RedelegationEpoch = epoch // Enable redelegation + if test.stakingV2 { + config.StakingV2Epoch = epoch // Enable Staking V2 + } else { + config.StakingV2Epoch = big.NewInt(10000000) // EpochTBD - disable Staking V2 + } + + // Execute redelegation + ws, balanceDeducted, fromLockedTokens, err := VerifyAndDelegateFromMsg( + sdb, epoch, &msg, delegationIndex, config, + ) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify balance deducted + if balanceDeducted.Cmp(test.expectedBalanceDeducted) != 0 { + t.Errorf("Balance deducted mismatch: got %v, expected %v", balanceDeducted, test.expectedBalanceDeducted) + } + + // Verify fromLockedTokens + if test.expectedBalanceDeducted.Cmp(big.NewInt(0)) == 0 { + // All from locked tokens + if len(fromLockedTokens) == 0 { + t.Errorf("Expected fromLockedTokens to be non-empty") + } else if lockedAmt, ok := fromLockedTokens[validatorAddr]; !ok { + t.Errorf("Expected fromLockedTokens to contain validatorAddr") + } else if lockedAmt.Cmp(test.delegateAmount) != 0 { + t.Errorf("FromLockedTokens amount mismatch: got %v, expected %v", lockedAmt, test.delegateAmount) + } + } + + // Verify undelegations in the result + if len(ws) == 0 { + t.Fatal("Expected at least one validator wrapper") + } + + foundDelegation := false + for _, w := range ws { + if w.Address == validatorAddr { + for _, del := range w.Delegations { + if del.DelegatorAddress == delegatorAddr { + foundDelegation = true + + // Verify undelegations + if len(del.Undelegations) != len(test.expectedUndelegations) { + t.Errorf("Undelegations count mismatch: got %d, expected %d. Description: %s", + len(del.Undelegations), len(test.expectedUndelegations), test.description) + t.Logf("Got undelegations: %+v", del.Undelegations) + t.Logf("Expected undelegations: %+v", test.expectedUndelegations) + } else { + for i, expectedUndel := range test.expectedUndelegations { + if i >= len(del.Undelegations) { + t.Errorf("Missing undelegation at index %d", i) + continue + } + actualUndel := del.Undelegations[i] + if actualUndel.Amount.Cmp(expectedUndel.amount) != 0 { + t.Errorf("Undelegation[%d] amount mismatch: got %v, expected %v. Description: %s", + i, actualUndel.Amount, expectedUndel.amount, test.description) + } + if actualUndel.Epoch.Cmp(expectedUndel.epoch) != 0 { + t.Errorf("Undelegation[%d] epoch mismatch: got %v, expected %v", + i, actualUndel.Epoch, expectedUndel.epoch) + } + } + } + break + } + } + break + } + } + + if !foundDelegation { + t.Fatal("Could not find delegation in result") + } + }) + } +} From a92c530492892cf44839c4ec7fbd14e4918cf649 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Wed, 31 Dec 2025 23:39:29 +0800 Subject: [PATCH 03/23] Add batch delegation/undelegation operations for StakingV2 - Add BatchDelegate, BatchUndelegate, and UndelegateAll message types - Implement batch operations using DelegationIndex pattern (same as CollectRewards) - Add verification functions: VerifyAndBatchDelegateFromMsg, VerifyAndBatchUndelegateFromMsg, VerifyAndUndelegateAllFromMsg - Integrate batch operations into EVM context and transaction processing - Add StakingV2 epoch checks to gate new features - Update RLP encoding/decoding and transaction validation - UndelegateAll automatically finds and undelegates all active delegations --- core/blockchain_impl.go | 18 +++ core/evm.go | 190 +++++++++++++++++++++++++++++ core/staking_verifier.go | 229 +++++++++++++++++++++++++++++++++++ core/state_transition.go | 39 ++++++ core/tx_pool.go | 81 +++++++++++++ core/types/transaction.go | 16 ++- core/vm/evm.go | 6 + staking/types/messages.go | 159 ++++++++++++++++++++++++ staking/types/transaction.go | 6 + 9 files changed, 741 insertions(+), 3 deletions(-) diff --git a/core/blockchain_impl.go b/core/blockchain_impl.go index 0b0b084e7a..29a30dd84e 100644 --- a/core/blockchain_impl.go +++ b/core/blockchain_impl.go @@ -3190,6 +3190,24 @@ func (bc *BlockChainImpl) prepareStakingMetaData( case staking.DirectiveUndelegate: case staking.DirectiveCollectRewards: + case staking.DirectiveBatchDelegate: + batchDelegate := decodePayload.(*staking.BatchDelegate) + for _, delegationAction := range batchDelegate.Delegations { + delegate := &staking.Delegate{ + DelegatorAddress: batchDelegate.DelegatorAddress, + ValidatorAddress: delegationAction.ValidatorAddress, + Amount: delegationAction.Amount, + } + if err := processDelegateMetadata(delegate, + newDelegations, + state, + bc, + blockNum); err != nil { + return nil, nil, err + } + } + case staking.DirectiveBatchUndelegate: + case staking.DirectiveUndelegateAll: default: } } diff --git a/core/evm.go b/core/evm.go index d959bf990f..beee0c6d58 100644 --- a/core/evm.go +++ b/core/evm.go @@ -93,6 +93,9 @@ func NewEVMBlockContext(msg Message, header *block.Header, chain ChainContext, a Delegate: DelegateFn(header, chain), Undelegate: UndelegateFn(header, chain), CollectRewards: CollectRewardsFn(header, chain), + BatchDelegate: BatchDelegateFn(header, chain), + BatchUndelegate: BatchUndelegateFn(header, chain), + UndelegateAll: UndelegateAllFn(header, chain), CalculateMigrationGas: CalculateMigrationGasFn(chain), ShardID: chain.ShardID(), NumShards: shard.Schedule.InstanceForEpoch(header.Epoch()).NumShards(), @@ -329,6 +332,193 @@ func CollectRewardsFn(ref *block.Header, chain ChainContext) vm.CollectRewardsFu } } +func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc { + return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, batchDelegate *stakingTypes.BatchDelegate) error { + delegations, err := chain.ReadDelegationsByDelegatorAt(batchDelegate.DelegatorAddress, big.NewInt(0).Sub(ref.Number(), big.NewInt(1))) + if err != nil { + return err + } + updatedValidatorWrappers, balanceToBeDeducted, fromLockedTokens, err := VerifyAndBatchDelegateFromMsg( + db, ref.Epoch(), batchDelegate, delegations, chain.Config()) + if err != nil { + return err + } + for _, wrapper := range updatedValidatorWrappers { + if err := db.UpdateValidatorWrapperWithRevert(wrapper.Address, wrapper); err != nil { + return err + } + } + + db.SubBalance(batchDelegate.DelegatorAddress, balanceToBeDeducted) + + if rosettaTracer != nil && balanceToBeDeducted.Sign() != 0 { + for _, delegationAction := range batchDelegate.Delegations { + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + }, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &delegationAction.ValidatorAddress, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + delegationAction.Amount, + ) + } + } + + if len(fromLockedTokens) > 0 { + sortedKeys := []common.Address{} + for key := range fromLockedTokens { + sortedKeys = append(sortedKeys, key) + } + sort.SliceStable(sortedKeys, func(i, j int) bool { + return bytes.Compare(sortedKeys[i][:], sortedKeys[j][:]) < 0 + }) + for _, key := range sortedKeys { + redelegatedToken, ok := fromLockedTokens[key] + if !ok { + return errors.New("Key missing for delegation receipt") + } + encodedRedelegationData := []byte{} + addrBytes := key.Bytes() + encodedRedelegationData = append(encodedRedelegationData, addrBytes...) + encodedRedelegationData = append(encodedRedelegationData, redelegatedToken.Bytes()...) + db.AddLog(&types.Log{ + Address: batchDelegate.DelegatorAddress, + Topics: []common.Hash{staking.DelegateTopic}, + Data: encodedRedelegationData, + BlockNumber: ref.Number().Uint64(), + }) + + if rosettaTracer != nil { + fromAccount := common.BytesToAddress(key.Bytes()) + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &fromAccount, + Metadata: map[string]interface{}{"type": "undelegation"}, + }, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &fromAccount, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + redelegatedToken, + ) + } + } + } + return nil + } +} + +func BatchUndelegateFn(ref *block.Header, chain ChainContext) vm.BatchUndelegateFunc { + return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, batchUndelegate *stakingTypes.BatchUndelegate) error { + updatedValidatorWrappers, err := VerifyAndBatchUndelegateFromMsg(db, ref.Epoch(), batchUndelegate) + if err != nil { + return err + } + + for _, wrapper := range updatedValidatorWrappers { + if err := db.UpdateValidatorWrapperWithRevert(wrapper.Address, wrapper); err != nil { + return err + } + } + + if rosettaTracer != nil { + for i, delegationIndex := range batchUndelegate.DelegationIndexes { + amount := batchUndelegate.Amounts[i] + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchUndelegate.DelegatorAddress, + SubAccount: &delegationIndex.ValidatorAddress, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + &vm.RosettaLogAddressItem{ + Account: &batchUndelegate.DelegatorAddress, + SubAccount: &delegationIndex.ValidatorAddress, + Metadata: map[string]interface{}{"type": "undelegation"}, + }, + amount, + ) + } + } + + return nil + } +} + +func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc { + return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, undelegateAll *stakingTypes.UndelegateAll) error { + if chain == nil { + return errors.New("[UndelegateAll] No chain context provided") + } + delegations, err := chain.ReadDelegationsByDelegatorAt(undelegateAll.DelegatorAddress, big.NewInt(0).Sub(ref.Number(), big.NewInt(1))) + if err != nil { + return err + } + + // Track original amounts before undelegation for rosetta logging + originalAmounts := map[common.Address]*big.Int{} + for _, delegationIndex := range delegations { + if !db.IsValidator(delegationIndex.ValidatorAddress) { + continue + } + wrapper, err := db.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) + if err != nil { + continue + } + if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { + continue + } + delegation := &wrapper.Delegations[delegationIndex.Index] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), undelegateAll.DelegatorAddress.Bytes()) { + continue + } + if delegation.Amount.Cmp(common.Big0) > 0 { + originalAmounts[delegationIndex.ValidatorAddress] = new(big.Int).Set(delegation.Amount) + } + } + + updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( + db, ref.Epoch(), undelegateAll, delegations, + ) + if err != nil { + return err + } + for _, wrapper := range updatedValidatorWrappers { + if err := db.UpdateValidatorWrapperWithRevert(wrapper.Address, wrapper); err != nil { + return err + } + } + + if rosettaTracer != nil { + for validatorAddr, amount := range originalAmounts { + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &undelegateAll.DelegatorAddress, + SubAccount: &validatorAddr, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + &vm.RosettaLogAddressItem{ + Account: &undelegateAll.DelegatorAddress, + SubAccount: &validatorAddr, + Metadata: map[string]interface{}{"type": "undelegation"}, + }, + amount, + ) + } + } + + return nil + } +} + //func MigrateDelegationsFn(ref *block.Header, chain ChainContext) vm.MigrateDelegationsFunc { // return func(db vm.StateDB, migrationMsg *stakingTypes.MigrationMsg) ([]interface{}, error) { // // get existing delegations diff --git a/core/staking_verifier.go b/core/staking_verifier.go index be04022486..a6502f332a 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -454,6 +454,235 @@ func VerifyAndUndelegateFromMsg( return nil, errNoDelegationToUndelegate } +// VerifyAndBatchDelegateFromMsg verifies batch delegation message using the stateDB +// and returns all updated validator wrappers, total balance to be deducted, and locked tokens map. +// +// Note that this function never updates the stateDB, it only reads from stateDB. +func VerifyAndBatchDelegateFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchDelegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, +) ([]*staking.ValidatorWrapper, *big.Int, map[common.Address]*big.Int, error) { + if stateDB == nil { + return nil, nil, nil, errStateDBIsMissing + } + if epoch == nil { + return nil, nil, nil, errEpochMissing + } + if chainConfig == nil { + return nil, nil, nil, errors.New("chain config is required") + } + if !chainConfig.IsStakingV2(epoch) { + return nil, nil, nil, errors.New("batch delegation is only available in StakingV2 epoch") + } + if len(msg.Delegations) == 0 { + return nil, nil, nil, errors.New("batch delegation must contain at least one delegation") + } + + allUpdatedWrappers := []*staking.ValidatorWrapper{} + totalBalanceToDeduct := big.NewInt(0) + allFromLockedTokens := map[common.Address]*big.Int{} + wrapperMap := map[common.Address]*staking.ValidatorWrapper{} + + for _, delegationAction := range msg.Delegations { + if !stateDB.IsValidator(delegationAction.ValidatorAddress) { + return nil, nil, nil, errValidatorNotExist + } + if delegationAction.Amount == nil || delegationAction.Amount.Sign() == -1 { + return nil, nil, nil, errNegativeAmount + } + if delegationAction.Amount.Cmp(minimumDelegation) < 0 { + if chainConfig.IsMinDelegation100(epoch) { + if delegationAction.Amount.Cmp(minimumDelegationV2) < 0 { + return nil, nil, nil, errDelegationTooSmallV2 + } + } else { + return nil, nil, nil, errDelegationTooSmall + } + } + + delegateMsg := &staking.Delegate{ + DelegatorAddress: msg.DelegatorAddress, + ValidatorAddress: delegationAction.ValidatorAddress, + Amount: delegationAction.Amount, + } + + updatedWrappers, balanceToDeduct, fromLockedTokens, err := VerifyAndDelegateFromMsg( + stateDB, epoch, delegateMsg, delegations, chainConfig, + ) + if err != nil { + return nil, nil, nil, err + } + + for _, wrapper := range updatedWrappers { + if existingWrapper, exists := wrapperMap[wrapper.Address]; exists { + if existingWrapper != wrapper { + return nil, nil, nil, errors.New("duplicate validator wrapper in batch delegation") + } + } else { + wrapperMap[wrapper.Address] = wrapper + allUpdatedWrappers = append(allUpdatedWrappers, wrapper) + } + } + + totalBalanceToDeduct.Add(totalBalanceToDeduct, balanceToDeduct) + + for validatorAddr, amount := range fromLockedTokens { + if existingAmount, exists := allFromLockedTokens[validatorAddr]; exists { + allFromLockedTokens[validatorAddr] = new(big.Int).Add(existingAmount, amount) + } else { + allFromLockedTokens[validatorAddr] = new(big.Int).Set(amount) + } + } + } + + if totalBalanceToDeduct.Cmp(big.NewInt(0)) > 0 { + if !CanTransfer(stateDB, msg.DelegatorAddress, totalBalanceToDeduct) { + return nil, nil, nil, errors.Wrapf( + errInsufficientBalanceForStake, "insufficient balance for batch delegation: %v", + totalBalanceToDeduct, + ) + } + } + + return allUpdatedWrappers, totalBalanceToDeduct, allFromLockedTokens, nil +} + +// VerifyAndBatchUndelegateFromMsg verifies batch undelegation message using the stateDB +// and returns all updated validator wrappers. +// +// Note that this function never updates the stateDB, it only reads from stateDB. +func VerifyAndBatchUndelegateFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchUndelegate, +) ([]*staking.ValidatorWrapper, error) { + if stateDB == nil { + return nil, errStateDBIsMissing + } + if epoch == nil { + return nil, errEpochMissing + } + if len(msg.DelegationIndexes) == 0 { + return nil, errors.New("batch undelegation must contain at least one delegation index") + } + if len(msg.DelegationIndexes) != len(msg.Amounts) { + return nil, errors.New("delegation indexes and amounts must have the same length") + } + + allUpdatedWrappers := []*staking.ValidatorWrapper{} + wrapperMap := map[common.Address]*staking.ValidatorWrapper{} + + for i, delegationIndex := range msg.DelegationIndexes { + amount := msg.Amounts[i] + if amount == nil || amount.Sign() == -1 { + return nil, errNegativeAmount + } + + if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { + return nil, errValidatorNotExist + } + + var wrapper *staking.ValidatorWrapper + var exists bool + if wrapper, exists = wrapperMap[delegationIndex.ValidatorAddress]; !exists { + var err error + wrapper, err = stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, true) + if err != nil { + return nil, err + } + wrapperMap[delegationIndex.ValidatorAddress] = wrapper + } + + if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { + utils.Logger().Warn(). + Str("validator", delegationIndex.ValidatorAddress.String()). + Uint64("delegation index", delegationIndex.Index). + Int("delegations length", len(wrapper.Delegations)). + Msg("Delegation index out of bound") + return nil, errors.New("Delegation index out of bound") + } + + delegation := &wrapper.Delegations[delegationIndex.Index] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + return nil, errors.New("delegator address mismatch") + } + + if err := delegation.Undelegate(epoch, amount); err != nil { + return nil, err + } + } + + for _, wrapper := range wrapperMap { + if err := wrapper.SanityCheck(); err != nil { + if errors.Cause(err) == staking.ErrInvalidSelfDelegation { + wrapper.Status = effective.Inactive + } else { + return nil, err + } + } + allUpdatedWrappers = append(allUpdatedWrappers, wrapper) + } + + return allUpdatedWrappers, nil +} + +// VerifyAndUndelegateAllFromMsg verifies and prepares undelegation of all delegations +// for a delegator. It reads all delegations and creates a batch undelegation. +// +// Note that this function never updates the stateDB, it only reads from stateDB. +func VerifyAndUndelegateAllFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, +) ([]*staking.ValidatorWrapper, error) { + if stateDB == nil { + return nil, errStateDBIsMissing + } + if epoch == nil { + return nil, errEpochMissing + } + if len(delegations) == 0 { + return nil, errors.New("no delegations to undelegate") + } + + delegationIndexes := []staking.DelegationIndex{} + amounts := []*big.Int{} + + for _, delegationIndex := range delegations { + if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { + continue + } + + wrapper, err := stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) + if err != nil { + return nil, err + } + + if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { + continue + } + + delegation := &wrapper.Delegations[delegationIndex.Index] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + continue + } + + if delegation.Amount.Cmp(common.Big0) <= 0 { + continue + } + + delegationIndexes = append(delegationIndexes, delegationIndex) + amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + } + + if len(delegationIndexes) == 0 { + return nil, errors.New("no active delegations to undelegate") + } + + batchUndelegateMsg := &staking.BatchUndelegate{ + DelegatorAddress: msg.DelegatorAddress, + DelegationIndexes: delegationIndexes, + Amounts: amounts, + } + + return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg) +} + // VerifyAndMigrateFromMsg verifies and transfers all delegations of // msg.From to msg.To. Returns all modified validator wrappers and delegate msgs // for metadata diff --git a/core/state_transition.go b/core/state_transition.go index 15003a487b..bf6687916e 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -413,6 +413,45 @@ func (st *StateTransition) StakingTransitionDb() (usedGas uint64, err error) { return 0, errInvalidSigner } err = st.evm.Context.CollectRewards(st.evm.StateDB, nil, stkMsg) + case types.BatchDelegate: + if !st.evm.ChainConfig().IsStakingV2(st.evm.Context.EpochNumber) { + return 0, errors.New("batch delegation is only available in StakingV2 epoch") + } + stkMsg := &stakingTypes.BatchDelegate{} + if err = rlp.DecodeBytes(msg.Data(), stkMsg); err != nil { + return 0, err + } + utils.Logger().Info().Msgf("[DEBUG STAKING] staking type: %s, gas: %d, txn: %+v", msg.Type(), gas, stkMsg) + if msg.From() != stkMsg.DelegatorAddress { + return 0, errInvalidSigner + } + err = st.evm.Context.BatchDelegate(st.evm.StateDB, nil, stkMsg) + case types.BatchUndelegate: + if !st.evm.ChainConfig().IsStakingV2(st.evm.Context.EpochNumber) { + return 0, errors.New("batch undelegation is only available in StakingV2 epoch") + } + stkMsg := &stakingTypes.BatchUndelegate{} + if err = rlp.DecodeBytes(msg.Data(), stkMsg); err != nil { + return 0, err + } + utils.Logger().Info().Msgf("[DEBUG STAKING] staking type: %s, gas: %d, txn: %+v", msg.Type(), gas, stkMsg) + if msg.From() != stkMsg.DelegatorAddress { + return 0, errInvalidSigner + } + err = st.evm.Context.BatchUndelegate(st.evm.StateDB, nil, stkMsg) + case types.UndelegateAll: + if !st.evm.ChainConfig().IsStakingV2(st.evm.Context.EpochNumber) { + return 0, errors.New("undelegate all is only available in StakingV2 epoch") + } + stkMsg := &stakingTypes.UndelegateAll{} + if err = rlp.DecodeBytes(msg.Data(), stkMsg); err != nil { + return 0, err + } + utils.Logger().Info().Msgf("[DEBUG STAKING] staking type: %s, gas: %d, txn: %+v", msg.Type(), gas, stkMsg) + if msg.From() != stkMsg.DelegatorAddress { + return 0, errInvalidSigner + } + err = st.evm.Context.UndelegateAll(st.evm.StateDB, nil, stkMsg) default: return 0, stakingTypes.ErrInvalidStakingKind } diff --git a/core/tx_pool.go b/core/tx_pool.go index eaa6fffe37..817fd19547 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -903,6 +903,87 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { _, _, err = VerifyAndCollectRewardsFromDelegation(pool.currentState, delegations) return err + case staking.DirectiveBatchDelegate: + pendingEpoch := pool.pendingEpoch() + if !pool.chainconfig.IsStakingV2(pendingEpoch) { + return errors.New("batch delegation is only available in StakingV2 epoch") + } + msg, err := staking.RLPDecodeStakeMsg(tx.Data(), staking.DirectiveBatchDelegate) + if err != nil { + return err + } + stkMsg, ok := msg.(*staking.BatchDelegate) + if !ok { + return ErrInvalidMsgForStakingDirective + } + if from != stkMsg.DelegatorAddress { + return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) + } + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(stkMsg.DelegatorAddress) + if err != nil { + return err + } + _, delegateAmt, _, err := VerifyAndBatchDelegateFromMsg( + pool.currentState, pendingEpoch, stkMsg, delegations, pool.chainconfig) + if err != nil { + return err + } + gasAmt := new(big.Int).Mul(tx.GasPrice(), new(big.Int).SetUint64(tx.GasLimit())) + totalAmt := new(big.Int).Add(delegateAmt, gasAmt) + if bal := pool.currentState.GetBalance(from); bal.Cmp(totalAmt) < 0 { + return fmt.Errorf("not enough balance for batch delegation: %v < %v", bal, delegateAmt) + } + return nil + case staking.DirectiveBatchUndelegate: + pendingEpoch := pool.pendingEpoch() + if !pool.chainconfig.IsStakingV2(pendingEpoch) { + return errors.New("batch undelegation is only available in StakingV2 epoch") + } + msg, err := staking.RLPDecodeStakeMsg(tx.Data(), staking.DirectiveBatchUndelegate) + if err != nil { + return err + } + stkMsg, ok := msg.(*staking.BatchUndelegate) + if !ok { + return ErrInvalidMsgForStakingDirective + } + if from != stkMsg.DelegatorAddress { + return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) + } + _, err = VerifyAndBatchUndelegateFromMsg(pool.currentState, pendingEpoch, stkMsg) + return err + case staking.DirectiveUndelegateAll: + pendingEpoch := pool.pendingEpoch() + if !pool.chainconfig.IsStakingV2(pendingEpoch) { + return errors.New("undelegate all is only available in StakingV2 epoch") + } + msg, err := staking.RLPDecodeStakeMsg(tx.Data(), staking.DirectiveUndelegateAll) + if err != nil { + return err + } + stkMsg, ok := msg.(*staking.UndelegateAll) + if !ok { + return ErrInvalidMsgForStakingDirective + } + if from != stkMsg.DelegatorAddress { + return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) + } + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(stkMsg.DelegatorAddress) + if err != nil { + return err + } + _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations) + return err default: return staking.ErrInvalidStakingKind } diff --git a/core/types/transaction.go b/core/types/transaction.go index 1364ccfac5..bc0a89dd3a 100644 --- a/core/types/transaction.go +++ b/core/types/transaction.go @@ -63,12 +63,22 @@ const ( Delegate Undelegate CollectRewards + BatchDelegate + BatchUndelegate + UndelegateAll ) // StakingTypeMap is the map from staking type to transactionType -var StakingTypeMap = map[staking.Directive]TransactionType{staking.DirectiveCreateValidator: StakeCreateVal, - staking.DirectiveEditValidator: StakeEditVal, staking.DirectiveDelegate: Delegate, - staking.DirectiveUndelegate: Undelegate, staking.DirectiveCollectRewards: CollectRewards} +var StakingTypeMap = map[staking.Directive]TransactionType{ + staking.DirectiveCreateValidator: StakeCreateVal, + staking.DirectiveEditValidator: StakeEditVal, + staking.DirectiveDelegate: Delegate, + staking.DirectiveUndelegate: Undelegate, + staking.DirectiveCollectRewards: CollectRewards, + staking.DirectiveBatchDelegate: BatchDelegate, + staking.DirectiveBatchUndelegate: BatchUndelegate, + staking.DirectiveUndelegateAll: UndelegateAll, +} // InternalTransaction defines the common interface for harmony and ethereum transactions. type InternalTransaction interface { diff --git a/core/vm/evm.go b/core/vm/evm.go index 764f51f4f1..d13499bb69 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -61,6 +61,9 @@ type ( DelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.Delegate) error UndelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.Undelegate) error CollectRewardsFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.CollectRewards) error + BatchDelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.BatchDelegate) error + BatchUndelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.BatchUndelegate) error + UndelegateAllFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.UndelegateAll) error // Used for migrating delegations via the staking precompile //MigrateDelegationsFunc func(db StateDB, migrationMsg *stakingTypes.MigrationMsg) ([]interface{}, error) CalculateMigrationGasFunc func(db StateDB, migrationMsg *stakingTypes.MigrationMsg, homestead bool, istanbul bool) (uint64, error) @@ -180,6 +183,9 @@ type BlockContext struct { Delegate DelegateFunc Undelegate UndelegateFunc CollectRewards CollectRewardsFunc + BatchDelegate BatchDelegateFunc + BatchUndelegate BatchUndelegateFunc + UndelegateAll UndelegateAllFunc CalculateMigrationGas CalculateMigrationGasFunc ShardID uint32 // Used by staking and cross shard transfer precompile diff --git a/staking/types/messages.go b/staking/types/messages.go index bddcbacf0b..1102f83868 100644 --- a/staking/types/messages.go +++ b/staking/types/messages.go @@ -27,6 +27,12 @@ const ( DirectiveUndelegate // DirectiveCollectRewards ... DirectiveCollectRewards + // DirectiveBatchDelegate ... + DirectiveBatchDelegate + // DirectiveBatchUndelegate ... + DirectiveBatchUndelegate + // DirectiveUndelegateAll ... + DirectiveUndelegateAll ) var ( @@ -36,6 +42,9 @@ var ( DirectiveDelegate: "Delegate", DirectiveUndelegate: "Undelegate", DirectiveCollectRewards: "CollectRewards", + DirectiveBatchDelegate: "BatchDelegate", + DirectiveBatchUndelegate: "BatchUndelegate", + DirectiveUndelegateAll: "UndelegateAll", } // ErrInvalidStakingKind given when caller gives bad staking message kind ErrInvalidStakingKind = errors.New("bad staking kind") @@ -263,3 +272,153 @@ func (v MigrationMsg) Copy() MigrationMsg { func (v MigrationMsg) Equals(s MigrationMsg) bool { return v.From == s.From && v.To == s.To } + +// DelegationAction represents a single delegation action in a batch operation +type DelegationAction struct { + ValidatorAddress common.Address `json:"validator_address"` + Amount *big.Int `json:"amount"` +} + +// BatchDelegate - type for delegating to multiple validators in one transaction +type BatchDelegate struct { + DelegatorAddress common.Address `json:"delegator_address"` + Delegations []DelegationAction `json:"delegations"` +} + +// Type of BatchDelegate +func (v BatchDelegate) Type() Directive { + return DirectiveBatchDelegate +} + +// Copy returns a deep copy of the BatchDelegate as a StakeMsg interface +func (v BatchDelegate) Copy() StakeMsg { + cp := BatchDelegate{ + DelegatorAddress: v.DelegatorAddress, + Delegations: make([]DelegationAction, len(v.Delegations)), + } + for i, d := range v.Delegations { + cp.Delegations[i] = DelegationAction{ + ValidatorAddress: d.ValidatorAddress, + } + if d.Amount != nil { + cp.Delegations[i].Amount = new(big.Int).Set(d.Amount) + } + } + return cp +} + +// Equals returns if v and s are equal +func (v BatchDelegate) Equals(s BatchDelegate) bool { + if !bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) { + return false + } + if len(v.Delegations) != len(s.Delegations) { + return false + } + for i := range v.Delegations { + if !bytes.Equal(v.Delegations[i].ValidatorAddress.Bytes(), s.Delegations[i].ValidatorAddress.Bytes()) { + return false + } + if v.Delegations[i].Amount == nil { + if s.Delegations[i].Amount != nil { + return false + } + } else if s.Delegations[i].Amount == nil { + return false + } else if v.Delegations[i].Amount.Cmp(s.Delegations[i].Amount) != 0 { + return false + } + } + return true +} + +// BatchUndelegate - type for undelegating from multiple validators in one transaction +type BatchUndelegate struct { + DelegatorAddress common.Address `json:"delegator_address"` + DelegationIndexes []DelegationIndex `json:"delegation_indexes"` + Amounts []*big.Int `json:"amounts"` +} + +// Type of BatchUndelegate +func (v BatchUndelegate) Type() Directive { + return DirectiveBatchUndelegate +} + +// Copy returns a deep copy of the BatchUndelegate as a StakeMsg interface +func (v BatchUndelegate) Copy() StakeMsg { + cp := BatchUndelegate{ + DelegatorAddress: v.DelegatorAddress, + DelegationIndexes: make([]DelegationIndex, len(v.DelegationIndexes)), + Amounts: make([]*big.Int, len(v.Amounts)), + } + for i, idx := range v.DelegationIndexes { + cp.DelegationIndexes[i] = DelegationIndex{ + ValidatorAddress: idx.ValidatorAddress, + Index: idx.Index, + } + if idx.BlockNum != nil { + cp.DelegationIndexes[i].BlockNum = new(big.Int).Set(idx.BlockNum) + } + } + for i, amt := range v.Amounts { + if amt != nil { + cp.Amounts[i] = new(big.Int).Set(amt) + } + } + return cp +} + +// Equals returns if v and s are equal +func (v BatchUndelegate) Equals(s BatchUndelegate) bool { + if !bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) { + return false + } + if len(v.DelegationIndexes) != len(s.DelegationIndexes) { + return false + } + if len(v.Amounts) != len(s.Amounts) { + return false + } + for i := range v.DelegationIndexes { + if !bytes.Equal(v.DelegationIndexes[i].ValidatorAddress.Bytes(), s.DelegationIndexes[i].ValidatorAddress.Bytes()) { + return false + } + if v.DelegationIndexes[i].Index != s.DelegationIndexes[i].Index { + return false + } + } + for i := range v.Amounts { + if v.Amounts[i] == nil { + if s.Amounts[i] != nil { + return false + } + } else if s.Amounts[i] == nil { + return false + } else if v.Amounts[i].Cmp(s.Amounts[i]) != 0 { + return false + } + } + return true +} + +// UndelegateAll - type for undelegating all from all validators +type UndelegateAll struct { + DelegatorAddress common.Address `json:"delegator_address"` +} + +// Type of UndelegateAll +func (v UndelegateAll) Type() Directive { + return DirectiveUndelegateAll +} + +// Copy returns a deep copy of the UndelegateAll as a StakeMsg interface +func (v UndelegateAll) Copy() StakeMsg { + return UndelegateAll{ + DelegatorAddress: v.DelegatorAddress, + } +} + +// Equals returns if v and s are equal +func (v UndelegateAll) Equals(s UndelegateAll) bool { + return bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) +} diff --git a/staking/types/transaction.go b/staking/types/transaction.go index c9923bcdfe..0d011bcd74 100644 --- a/staking/types/transaction.go +++ b/staking/types/transaction.go @@ -299,6 +299,12 @@ func RLPDecodeStakeMsg(payload []byte, d Directive) (interface{}, error) { ds = &Undelegate{} case DirectiveCollectRewards: ds = &CollectRewards{} + case DirectiveBatchDelegate: + ds = &BatchDelegate{} + case DirectiveBatchUndelegate: + ds = &BatchUndelegate{} + case DirectiveUndelegateAll: + ds = &UndelegateAll{} default: return nil, nil } From 8a55e9ee613022f35dc878e43634b33623388695 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Wed, 31 Dec 2025 23:54:09 +0800 Subject: [PATCH 04/23] Add tests for batch delegation/undelegation operations - Add TestVerifyAndBatchDelegateFromMsg with comprehensive test cases - Add TestVerifyAndBatchUndelegateFromMsg with error cases - Add TestVerifyAndUndelegateAllFromMsg for undelegate all functionality - Tests follow same pattern as existing delegate/undelegate tests - Include StakingV2 epoch validation tests --- core/staking_verifier_test.go | 482 ++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index cedc206458..91e751d90c 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -2177,3 +2177,485 @@ func TestRedelegationCornerCases(t *testing.T) { }) } } + +func TestVerifyAndBatchDelegateFromMsg(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + stakingV2Epoch := big.NewInt(defaultEpoch) + + tests := []struct { + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchDelegate + delegations []staking.DelegationIndex + chainConfig *params.ChainConfig + + expVWrappers []staking.ValidatorWrapper + expAmt *big.Int + expRedel map[common.Address]*big.Int + expErr error + }{ + { + name: "successful batch delegate to two validators", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + {ValidatorAddress: validatorAddr2, Amount: new(big.Int).Set(fiveKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + config.MinDelegation100Epoch = big.NewInt(100) + return config + }(), + expVWrappers: func() []staking.ValidatorWrapper { + w1 := makeVWrapperByIndex(validatorIndex) + w1.Delegations = append(w1.Delegations, staking.NewDelegation(delegatorAddr, tenKOnes)) + w2 := makeVWrapperByIndex(validator2Index) + w2.Delegations = append(w2.Delegations, staking.NewDelegation(delegatorAddr, fiveKOnes)) + return []staking.ValidatorWrapper{w1, w2} + }(), + expAmt: new(big.Int).Add(tenKOnes, fiveKOnes), + }, + { + name: "nil state db", + sdb: nil, + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errStateDBIsMissing, + }, + { + name: "nil epoch", + sdb: makeStateDBForStake(t), + epoch: nil, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errEpochMissing, + }, + { + name: "not StakingV2 epoch", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = big.NewInt(10000000) // Disabled + return config + }(), + expErr: errors.New("batch delegation is only available in StakingV2 epoch"), + }, + { + name: "empty delegations", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{}, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errors.New("batch delegation must contain at least one delegation"), + }, + { + name: "invalid validator", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: makeTestAddr("not exist"), Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errValidatorNotExist, + }, + { + name: "negative amount", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: big.NewInt(-1)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errNegativeAmount, + }, + { + name: "insufficient balance", + sdb: func() *state.DB { + sdb := makeStateDBForStake(t) + sdb.SetBalance(delegatorAddr, big.NewInt(100)) + return sdb + }(), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errInsufficientBalanceForStake, + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + ws, amt, amtRedel, err := VerifyAndBatchDelegateFromMsg( + test.sdb, test.epoch, &test.msg, test.delegations, test.chainConfig, + ) + + if assErr := assertError(err, test.expErr); assErr != nil { + t.Errorf("Test %v: %v", i, assErr) + } + if err != nil || test.expErr != nil { + return + } + + if amt.Cmp(test.expAmt) != 0 { + t.Errorf("Test %v: unexpected amount %v / %v", i, amt, test.expAmt) + } + + if len(amtRedel) != len(test.expRedel) { + t.Errorf("Test %v: wrong expected redelegation length %d / %d", i, len(amtRedel), len(test.expRedel)) + } else { + for key, value := range test.expRedel { + actValue, ok := amtRedel[key] + if !ok { + t.Errorf("Test %v: missing expected redelegation key/value %v / %v", i, key, value) + } + if value.Cmp(actValue) != 0 { + t.Errorf("Test %v: unexpected redelegation value %v / %v", i, actValue, value) + } + } + } + + if len(ws) != len(test.expVWrappers) { + t.Errorf("Test %v: wrong wrapper count %d / %d", i, len(ws), len(test.expVWrappers)) + return + } + + for j := range ws { + if err := staketest.CheckValidatorWrapperEqual(*ws[j], test.expVWrappers[j]); err != nil { + t.Errorf("Test %v wrapper %v: %v", i, j, err) + } + } + }) + } +} + +func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + + tests := []struct { + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchUndelegate + expErr error + }{ + { + name: "successful batch undelegate from two validators", + sdb: func() *state.DB { + sdb := makeDefaultStateForUndelegate(t) + w2 := makeVWrapperByIndex(validator2Index) + newDelegation2 := staking.NewDelegation(delegatorAddr, new(big.Int).Set(twentyKOnes)) + w2.Delegations = append(w2.Delegations, newDelegation2) + if err := sdb.UpdateValidatorWrapper(validatorAddr2, &w2); err != nil { + t.Fatal(err) + } + sdb.IntermediateRoot(true) + return sdb + }(), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{ + new(big.Int).Set(fiveKOnes), + new(big.Int).Set(fiveKOnes), + }, + }, + }, + { + name: "nil state db", + sdb: nil, + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errStateDBIsMissing, + }, + { + name: "nil epoch", + sdb: makeDefaultStateForUndelegate(t), + epoch: nil, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errEpochMissing, + }, + { + name: "empty delegation indexes", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{}, + Amounts: []*big.Int{}, + }, + expErr: errors.New("batch undelegation must contain at least one delegation index"), + }, + { + name: "mismatched lengths", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("delegation indexes and amounts must have the same length"), + }, + { + name: "invalid validator", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: makeTestAddr("not exist"), Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errValidatorNotExist, + }, + { + name: "negative amount", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{big.NewInt(-1)}, + }, + expErr: errNegativeAmount, + }, + { + name: "delegation index out of bound", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 999, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("Delegation index out of bound"), + }, + { + name: "delegator address mismatch", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: makeTestAddr("wrong delegator"), + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("delegator address mismatch"), + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + ws, err := VerifyAndBatchUndelegateFromMsg(test.sdb, test.epoch, &test.msg) + + if assErr := assertError(err, test.expErr); assErr != nil { + t.Errorf("Test %v: %v", i, assErr) + } + if err != nil || test.expErr != nil { + return + } + + if len(ws) == 0 { + t.Errorf("Test %v: expected at least one wrapper", i) + } + }) + } +} + +func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + + tests := []struct { + name string + sdb vm.StateDB + epoch *big.Int + msg staking.UndelegateAll + delegations []staking.DelegationIndex + expErr error + }{ + { + name: "successful undelegate all", + sdb: func() *state.DB { + sdb := makeDefaultStateForUndelegate(t) + w2 := makeVWrapperByIndex(validator2Index) + newDelegation2 := staking.NewDelegation(delegatorAddr, new(big.Int).Set(twentyKOnes)) + w2.Delegations = append(w2.Delegations, newDelegation2) + if err := sdb.UpdateValidatorWrapper(validatorAddr2, &w2); err != nil { + t.Fatal(err) + } + sdb.IntermediateRoot(true) + return sdb + }(), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: func() []staking.DelegationIndex { + return []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, + } + }(), + }, + { + name: "nil state db", + sdb: nil, + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + expErr: errStateDBIsMissing, + }, + { + name: "nil epoch", + sdb: makeDefaultStateForUndelegate(t), + epoch: nil, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + expErr: errEpochMissing, + }, + { + name: "no delegations", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + expErr: errors.New("no delegations to undelegate"), + }, + { + name: "no active delegations", + sdb: func() *state.DB { + sdb := makeStateDBForStake(t) + w, _ := sdb.ValidatorWrapper(validatorAddr, false, true) + delegation := staking.NewDelegation(delegatorAddr, big.NewInt(0)) + w.Delegations = append(w.Delegations, delegation) + sdb.UpdateValidatorWrapper(validatorAddr, w) + return sdb + }(), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + expErr: errors.New("no active delegations to undelegate"), + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations) + + if assErr := assertError(err, test.expErr); assErr != nil { + t.Errorf("Test %v: %v", i, assErr) + } + if err != nil || test.expErr != nil { + return + } + + if len(ws) == 0 { + t.Errorf("Test %v: expected at least one wrapper", i) + } + }) + } +} From 66306cd7905e5e13a42cc577f8fdaa72e7a958f1 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Thu, 1 Jan 2026 00:03:31 +0800 Subject: [PATCH 05/23] Fix UndelegateAll to include delegations created in same block - Update VerifyAndUndelegateAllFromMsg to scan all validators in current state - Ensures delegations created in same block are included when calling UndelegateAll - Add ChainContext parameter to enable validator list scanning - Update all call sites (evm.go, tx_pool.go, tests) to pass chainContext - Prevents missing delegations when user delegates then immediately calls UndelegateAll --- core/evm.go | 2 +- core/staking_verifier.go | 69 ++++++++++++++++++++++++++++++++--- core/staking_verifier_test.go | 31 ++++++++++++++-- core/tx_pool.go | 2 +- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/core/evm.go b/core/evm.go index beee0c6d58..3af82242bf 100644 --- a/core/evm.go +++ b/core/evm.go @@ -485,7 +485,7 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc } updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( - db, ref.Epoch(), undelegateAll, delegations, + db, ref.Epoch(), undelegateAll, delegations, chain, ) if err != nil { return err diff --git a/core/staking_verifier.go b/core/staking_verifier.go index a6502f332a..964517976d 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -624,11 +624,12 @@ func VerifyAndBatchUndelegateFromMsg( } // VerifyAndUndelegateAllFromMsg verifies and prepares undelegation of all delegations -// for a delegator. It reads all delegations and creates a batch undelegation. +// for a delegator. It reads all delegations from the current state and creates a batch undelegation. +// This ensures delegations created in the same block are included. // // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndUndelegateAllFromMsg( - stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, + stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, ) ([]*staking.ValidatorWrapper, error) { if stateDB == nil { return nil, errStateDBIsMissing @@ -636,13 +637,12 @@ func VerifyAndUndelegateAllFromMsg( if epoch == nil { return nil, errEpochMissing } - if len(delegations) == 0 { - return nil, errors.New("no delegations to undelegate") - } delegationIndexes := []staking.DelegationIndex{} amounts := []*big.Int{} + processedValidators := map[common.Address]map[uint64]bool{} + // First, process delegations from the provided list (from previous block) for _, delegationIndex := range delegations { if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { continue @@ -650,7 +650,7 @@ func VerifyAndUndelegateAllFromMsg( wrapper, err := stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) if err != nil { - return nil, err + continue } if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { @@ -668,9 +668,66 @@ func VerifyAndUndelegateAllFromMsg( delegationIndexes = append(delegationIndexes, delegationIndex) amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + + // Track processed delegations to avoid duplicates + if processedValidators[delegationIndex.ValidatorAddress] == nil { + processedValidators[delegationIndex.ValidatorAddress] = make(map[uint64]bool) + } + processedValidators[delegationIndex.ValidatorAddress][delegationIndex.Index] = true } + // Then, scan all validators in current state to find any new delegations created in this block + if chainContext != nil { + validatorList, err := chainContext.ReadValidatorList() + if err == nil { + for _, validatorAddr := range validatorList { + if !stateDB.IsValidator(validatorAddr) { + continue + } + + wrapper, err := stateDB.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + continue + } + + // Check all delegations for this delegator + for i := range wrapper.Delegations { + delegation := &wrapper.Delegations[i] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + continue + } + + if delegation.Amount.Cmp(common.Big0) <= 0 { + continue + } + + // Skip if already processed + if processedValidators[validatorAddr] != nil && processedValidators[validatorAddr][uint64(i)] { + continue + } + + // Found a new delegation (created in this block) + delegationIndexes = append(delegationIndexes, staking.DelegationIndex{ + ValidatorAddress: validatorAddr, + Index: uint64(i), + BlockNum: big.NewInt(0), + }) + amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + + if processedValidators[validatorAddr] == nil { + processedValidators[validatorAddr] = make(map[uint64]bool) + } + processedValidators[validatorAddr][uint64(i)] = true + } + } + } + } + + // If no delegations found and no chain context to scan, return error if len(delegationIndexes) == 0 { + if chainContext == nil && len(delegations) == 0 { + return nil, errors.New("no delegations to undelegate") + } return nil, errors.New("no active delegations to undelegate") } diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 91e751d90c..293e26ece5 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -2565,6 +2565,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { epoch *big.Int msg staking.UndelegateAll delegations []staking.DelegationIndex + chain ChainContext expErr error }{ { @@ -2590,6 +2591,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, } }(), + chain: makeFakeChainContextForStake(), }, { name: "nil state db", @@ -2599,6 +2601,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { DelegatorAddress: delegatorAddr, }, delegations: []staking.DelegationIndex{}, + chain: makeFakeChainContextForStake(), expErr: errStateDBIsMissing, }, { @@ -2609,17 +2612,36 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { DelegatorAddress: delegatorAddr, }, delegations: []staking.DelegationIndex{}, + chain: makeFakeChainContextForStake(), expErr: errEpochMissing, }, { - name: "no delegations", - sdb: makeDefaultStateForUndelegate(t), + name: "no delegations in list but found in state scan", + sdb: func() *state.DB { + sdb := makeDefaultStateForUndelegate(t) + return sdb + }(), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + chain: makeFakeChainContextForStake(), + expErr: nil, + }, + { + name: "no delegations at all", + sdb: func() *state.DB { + sdb := makeStateDBForStake(t) + return sdb + }(), epoch: epoch, msg: staking.UndelegateAll{ DelegatorAddress: delegatorAddr, }, delegations: []staking.DelegationIndex{}, - expErr: errors.New("no delegations to undelegate"), + chain: makeFakeChainContextForStake(), + expErr: errors.New("no active delegations to undelegate"), }, { name: "no active delegations", @@ -2638,13 +2660,14 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { delegations: []staking.DelegationIndex{ {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, }, + chain: makeFakeChainContextForStake(), expErr: errors.New("no active delegations to undelegate"), }, } for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations) + ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) diff --git a/core/tx_pool.go b/core/tx_pool.go index 817fd19547..dc0accd6da 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -982,7 +982,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if err != nil { return err } - _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations) + _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain) return err default: return staking.ErrInvalidStakingKind From cf59cae8f4517dd6ea09c781c549a1c9b224fba0 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Wed, 15 Jul 2026 01:22:51 +0800 Subject: [PATCH 06/23] staking v2: batch delegation, tx pool validation, and precompile revert --- accounts/abi/abi.go | 13 +++ core/tx_pool.go | 97 +++++++++++++++++-- core/vm/contracts.go | 22 ++++- core/vm/precompile_revert_test.go | 81 ++++++++++++++++ internal/params/config.go | 4 +- rpc/harmony/staking.go | 9 +- staking/precompile_address.go | 6 ++ staking/types/delegation.go | 13 +++ staking/types/delegation_redelegation_test.go | 20 ++++ 9 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 core/vm/precompile_revert_test.go create mode 100644 staking/precompile_address.go create mode 100644 staking/types/delegation_redelegation_test.go diff --git a/accounts/abi/abi.go b/accounts/abi/abi.go index 9950e13725..34895f04e1 100644 --- a/accounts/abi/abi.go +++ b/accounts/abi/abi.go @@ -261,6 +261,19 @@ func (abi *ABI) HasReceive() bool { // revertSelector is a special function selector for revert reason unpacking. var revertSelector = crypto.Keccak256([]byte("Error(string)"))[:4] +// PackRevert ABI-encodes a revert reason using the standard Error(string) selector. +func PackRevert(reason string) ([]byte, error) { + typ, err := NewType("string", "", nil) + if err != nil { + return nil, err + } + packed, err := (Arguments{{Type: typ}}).Pack(reason) + if err != nil { + return nil, err + } + return append(append([]byte(nil), revertSelector...), packed...), nil +} + // UnpackRevert resolves the abi-encoded revert reason. According to the solidity // spec https://solidity.readthedocs.io/en/latest/control-structures.html#revert, // the provided revert reason is abi-encoded as if it were a call to a function diff --git a/core/tx_pool.go b/core/tx_pool.go index f5390b7b58..6b8e577b9b 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -38,6 +38,7 @@ import ( hmyCommon "github.com/harmony-one/harmony/internal/common" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/shard" + stakingabi "github.com/harmony-one/harmony/staking" staking "github.com/harmony-one/harmony/staking/types" ) @@ -754,13 +755,16 @@ func (pool *TxPool) validateTx(tx types.PoolTransaction, local bool) error { return err } stakingTx, isStakingTx := tx.(*staking.StakingTransaction) + isPrecompileDelegate := pool.isStakingPrecompileDelegate(tx, from) if !isStakingTx || (isStakingTx && stakingTx.StakingType() != staking.DirectiveDelegate) { - if pool.currentState.GetBalance(from).Cmp(cost) < 0 { - return errors.Wrapf( - ErrInsufficientFunds, - "current shard-id: %d", - pool.chain.CurrentBlock().ShardID(), - ) + if !isPrecompileDelegate { + if pool.currentState.GetBalance(from).Cmp(cost) < 0 { + return errors.Wrapf( + ErrInsufficientFunds, + "current shard-id: %d", + pool.chain.CurrentBlock().ShardID(), + ) + } } } intrGas := uint64(0) @@ -779,7 +783,86 @@ func (pool *TxPool) validateTx(tx types.PoolTransaction, local bool) error { if isStakingTx { return pool.validateStakingTx(stakingTx) } - return nil + return pool.validateStakingPrecompileCall(tx, from) +} + +func (pool *TxPool) isStakingPrecompileDelegate(tx types.PoolTransaction, from common.Address) bool { + if pool.chain.CurrentBlock().ShardID() != shard.BeaconChainShardID { + return false + } + if !pool.chainconfig.IsStakingPrecompile(pool.pendingEpoch()) { + return false + } + to := tx.To() + if to == nil || *to != stakingabi.PrecompileAddress { + return false + } + stakeMsg, err := stakingabi.ParseStakeMsg(from, tx.Data()) + if err != nil { + return false + } + _, ok := stakeMsg.(*staking.Delegate) + return ok +} + +func (pool *TxPool) validateStakingPrecompileCall(tx types.PoolTransaction, from common.Address) error { + if pool.chain.CurrentBlock().ShardID() != shard.BeaconChainShardID { + return nil + } + if !pool.chainconfig.IsStakingPrecompile(pool.pendingEpoch()) { + return nil + } + to := tx.To() + if to == nil || *to != stakingabi.PrecompileAddress { + return nil + } + stakeMsg, err := stakingabi.ParseStakeMsg(from, tx.Data()) + if err != nil { + return err + } + + b32, _ := hmyCommon.AddressToBech32(from) + switch msg := stakeMsg.(type) { + case *staking.Delegate: + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(msg.DelegatorAddress) + if err != nil { + return err + } + pendingEpoch := pool.pendingEpoch() + _, delegateAmt, _, err := VerifyAndDelegateFromMsg( + pool.currentState, pendingEpoch, msg, delegations, pool.chainconfig) + if err != nil { + return err + } + gasAmt := new(big.Int).Mul(tx.GasPrice(), new(big.Int).SetUint64(tx.GasLimit())) + totalAmt := new(big.Int).Add(delegateAmt, gasAmt) + if bal := pool.currentState.GetBalance(from); bal.Cmp(totalAmt) < 0 { + return fmt.Errorf("not enough balance for delegation: %v < %v", bal, delegateAmt) + } + return nil + case *staking.Undelegate: + _, err := VerifyAndUndelegateFromMsg(pool.currentState, pool.pendingEpoch(), msg) + return err + case *staking.CollectRewards: + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(msg.DelegatorAddress) + if err != nil { + return err + } + _, _, err = VerifyAndCollectRewardsFromDelegation(pool.currentState, delegations) + return err + default: + return errors.WithMessagef(ErrInvalidSender, "staking precompile sender is %s", b32) + } } // validateStakingTx checks the staking message based on the staking directive diff --git a/core/vm/contracts.go b/core/vm/contracts.go index 59ec9549eb..b8aca6b388 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -25,6 +25,7 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/accounts/abi" "github.com/harmony-one/harmony/internal/params" "github.com/ethereum/go-ethereum/common/math" @@ -295,14 +296,31 @@ func RunPrecompiledContract(p WriteCapablePrecompiledContract, evm *EVM, contrac } gasCost, err := p.RequiredGas(evm, contract, input) if err != nil { - return nil, 0, err + return wrapWritePrecompileError(evm, nil, 0, err) } if suppliedGas < gasCost { return nil, 0, ErrOutOfGas } suppliedGas -= gasCost output, err := p.RunWriteCapable(evm, contract, input) - return output, suppliedGas, err + return wrapWritePrecompileError(evm, output, suppliedGas, err) +} + +func wrapWritePrecompileError( + evm *EVM, output []byte, remainingGas uint64, err error, +) ([]byte, uint64, error) { + if err == nil || err == ErrExecutionReverted { + return output, remainingGas, err + } + if evm == nil || evm.ChainConfig() == nil || + !evm.ChainConfig().IsStakingV2(evm.Context.EpochNumber) { + return output, remainingGas, err + } + revertData, packErr := abi.PackRevert(err.Error()) + if packErr != nil { + return output, remainingGas, err + } + return revertData, remainingGas, ErrExecutionReverted } // ECRECOVER implemented as a native contract. diff --git a/core/vm/precompile_revert_test.go b/core/vm/precompile_revert_test.go new file mode 100644 index 0000000000..9c2f8b87ba --- /dev/null +++ b/core/vm/precompile_revert_test.go @@ -0,0 +1,81 @@ +package vm + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/accounts/abi" + "github.com/harmony-one/harmony/internal/params" + "github.com/stretchr/testify/require" +) + +func TestWrapWritePrecompileErrorBeforeFork(t *testing.T) { + cfg := *params.TestChainConfig + cfg.StakingV2Epoch = big.NewInt(100) + evm := NewEVM( + BlockContext{EpochNumber: big.NewInt(1)}, + TxContext{}, + nil, + &cfg, + Config{}, + ) + + origErr := errors.New("insufficient balance to stake") + output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + require.Equal(t, origErr, err) + require.Equal(t, uint64(42_000), gas) + require.Nil(t, output) +} + +func TestWrapWritePrecompileErrorAfterFork(t *testing.T) { + evm := NewEVM( + BlockContext{EpochNumber: big.NewInt(6)}, + TxContext{}, + nil, + params.LocalnetChainConfig, + Config{}, + ) + + origErr := errors.New("insufficient balance to stake") + output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + require.ErrorIs(t, err, ErrExecutionReverted) + require.Equal(t, uint64(42_000), gas) + + reason, unpackErr := abi.UnpackRevert(output) + require.NoError(t, unpackErr) + require.Equal(t, origErr.Error(), reason) +} + +func TestStakingPrecompileAddressMismatchRevertsAfterFork(t *testing.T) { + env := NewEVM(BlockContext{ + CollectRewards: CollectRewardsFn(), + Delegate: DelegateFn(), + Undelegate: UndelegateFn(), + CreateValidator: CreateValidatorFn(), + EditValidator: EditValidatorFn(), + ShardID: 0, + EpochNumber: big.NewInt(6), + CalculateMigrationGas: CalculateMigrationGasFn(), + }, TxContext{}, nil, params.LocalnetChainConfig, Config{}) + + input := []byte{ + 109, 107, 47, 119, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 56, + } + contract := NewContract( + AccountRef(common.HexToAddress("0x1337")), + AccountRef(common.HexToAddress("0x1338")), + nil, + 1_000_000, + ) + p := &stakingPrecompile{} + gas, err := p.RequiredGas(env, contract, input) + require.NoError(t, err) + contract.Gas = gas + + _, remainingGas, err := RunPrecompiledContract(p, env, contract, input, gas, false) + require.ErrorIs(t, err, ErrExecutionReverted) + require.NotZero(t, remainingGas) +} diff --git a/internal/params/config.go b/internal/params/config.go index 01cc2689db..2d45aca4f2 100644 --- a/internal/params/config.go +++ b/internal/params/config.go @@ -476,7 +476,7 @@ var ( SlashBallotSignerFixEpoch: big.NewInt(5), VerifyBeaconHeaderSlashEpoch: big.NewInt(5), BloomEpoch: big.NewInt(5), - StakingV2Epoch: EpochTBD, + StakingV2Epoch: big.NewInt(6), } // AllProtocolChanges ... @@ -628,7 +628,7 @@ var ( big.NewInt(1), // SlashBallotSignerFixEpoch big.NewInt(1), // VerifyBeaconHeaderSlashEpoch big.NewInt(1), // BloomEpoch - big.NewInt(0), // StakingV2Epoch + big.NewInt(0), // StakingV2Epoch } // TestRules ... diff --git a/rpc/harmony/staking.go b/rpc/harmony/staking.go index 1715a486b4..273c71af06 100644 --- a/rpc/harmony/staking.go +++ b/rpc/harmony/staking.go @@ -878,11 +878,10 @@ func (s *PublicStakingService) GetAvailableRedelegationBalance( redelegationTotal := big.NewInt(0) for _, d := range delegations { - for _, u := range d.Undelegations { - if u.Epoch.Cmp(currEpoch) < 1 { // Undelegation.Epoch < currentEpoch - redelegationTotal.Add(redelegationTotal, u.Amount) - } - } + redelegationTotal.Add( + redelegationTotal, + staking.TotalRedelegatableUndelegations(d.Undelegations, currEpoch), + ) } return redelegationTotal, nil } diff --git a/staking/precompile_address.go b/staking/precompile_address.go new file mode 100644 index 0000000000..b805020790 --- /dev/null +++ b/staking/precompile_address.go @@ -0,0 +1,6 @@ +package staking + +import "github.com/ethereum/go-ethereum/common" + +// PrecompileAddress is the EVM staking precompile at 0x…fc (decimal 252). +var PrecompileAddress = common.BytesToAddress([]byte{252}) diff --git a/staking/types/delegation.go b/staking/types/delegation.go index 65bf36cfe5..fef7fdfbe7 100644 --- a/staking/types/delegation.go +++ b/staking/types/delegation.go @@ -152,6 +152,19 @@ func (d *Delegation) Undelegate(epoch *big.Int, amt *big.Int) error { return nil } +// TotalRedelegatableUndelegations returns undelegated tokens eligible for redelegation +// at currEpoch. This matches the redelegation loop in core/staking_verifier.go, which +// only consumes entries with undelegation.Epoch strictly before the current epoch. +func TotalRedelegatableUndelegations(undelegations Undelegations, currEpoch *big.Int) *big.Int { + total := big.NewInt(0) + for _, u := range undelegations { + if u.Epoch.Cmp(currEpoch) < 0 { + total.Add(total, u.Amount) + } + } + return total +} + // TotalInUndelegation - return the total amount of token in undelegation (locking period) func (d *Delegation) TotalInUndelegation() *big.Int { total := big.NewInt(0) diff --git a/staking/types/delegation_redelegation_test.go b/staking/types/delegation_redelegation_test.go new file mode 100644 index 0000000000..b553410690 --- /dev/null +++ b/staking/types/delegation_redelegation_test.go @@ -0,0 +1,20 @@ +package types + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTotalRedelegatableUndelegations(t *testing.T) { + currEpoch := big.NewInt(2965) + delegations := Undelegations{ + {Amount: big.NewInt(100), Epoch: big.NewInt(2964)}, + {Amount: big.NewInt(200), Epoch: big.NewInt(2965)}, + {Amount: big.NewInt(300), Epoch: big.NewInt(2963)}, + } + + total := TotalRedelegatableUndelegations(delegations, currEpoch) + require.Equal(t, int64(400), total.Int64()) +} From e8790fdd95ea28624d86d120e6099271312296a6 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Fri, 17 Jul 2026 06:06:54 +0800 Subject: [PATCH 07/23] staking v2: fix batch composition, epoch gates, and precompile revert scope --- core/evm.go | 51 +++--- core/staking_verifier.go | 90 ++++++++--- core/staking_verifier_test.go | 255 ++++++++++++++++++++++++++---- core/tx_pool.go | 4 +- core/vm/contracts.go | 11 +- core/vm/precompile_revert_test.go | 30 +++- 6 files changed, 361 insertions(+), 80 deletions(-) diff --git a/core/evm.go b/core/evm.go index 80a2d7bb75..8578b90609 100644 --- a/core/evm.go +++ b/core/evm.go @@ -352,20 +352,23 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc db.SubBalance(batchDelegate.DelegatorAddress, balanceToBeDeducted) if rosettaTracer != nil && balanceToBeDeducted.Sign() != 0 { - for _, delegationAction := range batchDelegate.Delegations { - rosettaTracer.AddRosettaLog( - vm.CALL, - &vm.RosettaLogAddressItem{ - Account: &batchDelegate.DelegatorAddress, - }, - &vm.RosettaLogAddressItem{ - Account: &batchDelegate.DelegatorAddress, - SubAccount: &delegationAction.ValidatorAddress, - Metadata: map[string]interface{}{"type": "delegation"}, - }, - delegationAction.Amount, - ) - } + // Attribute liquid funds to each destination proportionally is not + // available without per-action liquid splits; log the aggregate + // liquid deduction once (matches single-Delegate amount semantics + // for the total liquid spent). + dest := batchDelegate.Delegations[0].ValidatorAddress + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + }, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &dest, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + balanceToBeDeducted, + ) } if len(fromLockedTokens) > 0 { @@ -376,6 +379,12 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc sort.SliceStable(sortedKeys, func(i, j int) bool { return bytes.Compare(sortedKeys[i][:], sortedKeys[j][:]) < 0 }) + // When the batch has a single destination, Rosetta can mirror + // single-Delegate logging (source undelegation -> dest delegation). + var singleDest *common.Address + if len(batchDelegate.Delegations) == 1 { + singleDest = &batchDelegate.Delegations[0].ValidatorAddress + } for _, key := range sortedKeys { redelegatedToken, ok := fromLockedTokens[key] if !ok { @@ -394,6 +403,12 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc if rosettaTracer != nil { fromAccount := common.BytesToAddress(key.Bytes()) + toAccount := fromAccount + metaType := "redelegation" + if singleDest != nil { + toAccount = *singleDest + metaType = "delegation" + } rosettaTracer.AddRosettaLog( vm.CALL, &vm.RosettaLogAddressItem{ @@ -403,8 +418,8 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc }, &vm.RosettaLogAddressItem{ Account: &batchDelegate.DelegatorAddress, - SubAccount: &fromAccount, - Metadata: map[string]interface{}{"type": "delegation"}, + SubAccount: &toAccount, + Metadata: map[string]interface{}{"type": metaType}, }, redelegatedToken, ) @@ -417,7 +432,7 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc func BatchUndelegateFn(ref *block.Header, chain ChainContext) vm.BatchUndelegateFunc { return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, batchUndelegate *stakingTypes.BatchUndelegate) error { - updatedValidatorWrappers, err := VerifyAndBatchUndelegateFromMsg(db, ref.Epoch(), batchUndelegate) + updatedValidatorWrappers, err := VerifyAndBatchUndelegateFromMsg(db, ref.Epoch(), batchUndelegate, chain.Config()) if err != nil { return err } @@ -485,7 +500,7 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc } updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( - db, ref.Epoch(), undelegateAll, delegations, chain, + db, ref.Epoch(), undelegateAll, delegations, chain, chain.Config(), ) if err != nil { return err diff --git a/core/staking_verifier.go b/core/staking_verifier.go index 1b2351854d..819b9b3834 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -277,6 +277,17 @@ var ( // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndDelegateFromMsg( stateDB vm.StateDB, epoch *big.Int, msg *staking.Delegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, +) ([]*staking.ValidatorWrapper, *big.Int, map[common.Address]*big.Int, error) { + return verifyAndDelegateFromMsg(stateDB, epoch, msg, delegations, chainConfig, nil) +} + +// verifyAndDelegateFromMsg is the shared implementation for single and batch +// delegation. When wrapperCache is non-nil, wrappers are reused across calls so +// batch actions compose against the same in-memory state (undelegations, +// amounts) without mutating stateDB. +func verifyAndDelegateFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.Delegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, + wrapperCache map[common.Address]*staking.ValidatorWrapper, ) ([]*staking.ValidatorWrapper, *big.Int, map[common.Address]*big.Int, error) { if stateDB == nil { return nil, nil, nil, errStateDBIsMissing @@ -297,6 +308,22 @@ func VerifyAndDelegateFromMsg( } } + getWrapper := func(addr common.Address) (*staking.ValidatorWrapper, error) { + if wrapperCache != nil { + if cached, ok := wrapperCache[addr]; ok { + return cached, nil + } + } + wrapper, err := stateDB.ValidatorWrapper(addr, false, true) + if err != nil { + return nil, err + } + if wrapperCache != nil { + wrapperCache[addr] = wrapper + } + return wrapper, nil + } + updatedValidatorWrappers := []*staking.ValidatorWrapper{} delegateBalance := big.NewInt(0).Set(msg.Amount) fromLockedTokens := map[common.Address]*big.Int{} @@ -306,8 +333,7 @@ func VerifyAndDelegateFromMsg( // Check if we can use tokens in undelegation to delegate (redelegate) for i := range delegations { delegationIndex := &delegations[i] - // request a copy, and since delegations will be changed, copy them too - wrapper, err := stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, true) + wrapper, err := getWrapper(delegationIndex.ValidatorAddress) if err != nil { return nil, nil, nil, err } @@ -407,8 +433,7 @@ func VerifyAndDelegateFromMsg( if delegateeWrapper == nil { var err error - // request a copy, and since delegations will be changed, copy them too - delegateeWrapper, err = stateDB.ValidatorWrapper(msg.ValidatorAddress, false, true) + delegateeWrapper, err = getWrapper(msg.ValidatorAddress) if err != nil { return nil, nil, nil, err } @@ -533,10 +558,9 @@ func VerifyAndBatchDelegateFromMsg( return nil, nil, nil, errors.New("batch delegation must contain at least one delegation") } - allUpdatedWrappers := []*staking.ValidatorWrapper{} + wrapperCache := map[common.Address]*staking.ValidatorWrapper{} totalBalanceToDeduct := big.NewInt(0) allFromLockedTokens := map[common.Address]*big.Int{} - wrapperMap := map[common.Address]*staking.ValidatorWrapper{} for _, delegationAction := range msg.Delegations { if !stateDB.IsValidator(delegationAction.ValidatorAddress) { @@ -561,24 +585,13 @@ func VerifyAndBatchDelegateFromMsg( Amount: delegationAction.Amount, } - updatedWrappers, balanceToDeduct, fromLockedTokens, err := VerifyAndDelegateFromMsg( - stateDB, epoch, delegateMsg, delegations, chainConfig, + _, balanceToDeduct, fromLockedTokens, err := verifyAndDelegateFromMsg( + stateDB, epoch, delegateMsg, delegations, chainConfig, wrapperCache, ) if err != nil { return nil, nil, nil, err } - for _, wrapper := range updatedWrappers { - if existingWrapper, exists := wrapperMap[wrapper.Address]; exists { - if existingWrapper != wrapper { - return nil, nil, nil, errors.New("duplicate validator wrapper in batch delegation") - } - } else { - wrapperMap[wrapper.Address] = wrapper - allUpdatedWrappers = append(allUpdatedWrappers, wrapper) - } - } - totalBalanceToDeduct.Add(totalBalanceToDeduct, balanceToDeduct) for validatorAddr, amount := range fromLockedTokens { @@ -599,6 +612,22 @@ func VerifyAndBatchDelegateFromMsg( } } + // Preserve stable insertion order from first touch in the cache. + allUpdatedWrappers := make([]*staking.ValidatorWrapper, 0, len(wrapperCache)) + seen := map[common.Address]bool{} + for _, delegationAction := range msg.Delegations { + if w, ok := wrapperCache[delegationAction.ValidatorAddress]; ok && !seen[w.Address] { + allUpdatedWrappers = append(allUpdatedWrappers, w) + seen[w.Address] = true + } + } + for addr, w := range wrapperCache { + if !seen[addr] { + allUpdatedWrappers = append(allUpdatedWrappers, w) + seen[addr] = true + } + } + return allUpdatedWrappers, totalBalanceToDeduct, allFromLockedTokens, nil } @@ -607,7 +636,7 @@ func VerifyAndBatchDelegateFromMsg( // // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndBatchUndelegateFromMsg( - stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchUndelegate, + stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchUndelegate, chainConfig *params.ChainConfig, ) ([]*staking.ValidatorWrapper, error) { if stateDB == nil { return nil, errStateDBIsMissing @@ -615,6 +644,12 @@ func VerifyAndBatchUndelegateFromMsg( if epoch == nil { return nil, errEpochMissing } + if chainConfig == nil { + return nil, errors.New("chain config is required") + } + if !chainConfig.IsStakingV2(epoch) { + return nil, errors.New("batch undelegation is only available in StakingV2 epoch") + } if len(msg.DelegationIndexes) == 0 { return nil, errors.New("batch undelegation must contain at least one delegation index") } @@ -685,7 +720,7 @@ func VerifyAndBatchUndelegateFromMsg( // // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndUndelegateAllFromMsg( - stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, + stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, chainConfig *params.ChainConfig, ) ([]*staking.ValidatorWrapper, error) { if stateDB == nil { return nil, errStateDBIsMissing @@ -693,6 +728,17 @@ func VerifyAndUndelegateAllFromMsg( if epoch == nil { return nil, errEpochMissing } + if chainConfig == nil { + if chainContext != nil { + chainConfig = chainContext.Config() + } + } + if chainConfig == nil { + return nil, errors.New("chain config is required") + } + if !chainConfig.IsStakingV2(epoch) { + return nil, errors.New("undelegate all is only available in StakingV2 epoch") + } delegationIndexes := []staking.DelegationIndex{} amounts := []*big.Int{} @@ -793,7 +839,7 @@ func VerifyAndUndelegateAllFromMsg( Amounts: amounts, } - return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg) + return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg, chainConfig) } // VerifyAndMigrateFromMsg verifies and transfers all delegations of diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 3d9e3bbcb6..0f3f52fc53 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -1751,6 +1751,8 @@ func (chain *fakeChainContext) Config() *params.ChainConfig { config := ¶ms.ChainConfig{} config.MinCommissionRateEpoch = big.NewInt(0) config.MinCommissionPromoPeriod = big.NewInt(10) + config.StakingV2Epoch = big.NewInt(0) + config.RedelegationEpoch = big.NewInt(0) return config } @@ -2399,13 +2401,19 @@ func TestVerifyAndBatchDelegateFromMsg(t *testing.T) { func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { epoch := big.NewInt(defaultEpoch) + stakingV2Config := func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + return config + } tests := []struct { - name string - sdb vm.StateDB - epoch *big.Int - msg staking.BatchUndelegate - expErr error + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchUndelegate + chainConfig *params.ChainConfig + expErr error }{ { name: "successful batch undelegate from two validators", @@ -2420,7 +2428,8 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { sdb.IntermediateRoot(true) return sdb }(), - epoch: epoch, + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2434,9 +2443,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { }, }, { - name: "nil state db", - sdb: nil, - epoch: epoch, + name: "nil state db", + sdb: nil, + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2447,9 +2457,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errStateDBIsMissing, }, { - name: "nil epoch", - sdb: makeDefaultStateForUndelegate(t), - epoch: nil, + name: "nil epoch", + sdb: makeDefaultStateForUndelegate(t), + epoch: nil, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2460,9 +2471,28 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errEpochMissing, }, { - name: "empty delegation indexes", + name: "not StakingV2 epoch", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = big.NewInt(10000000) + return config + }(), + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("batch undelegation is only available in StakingV2 epoch"), + }, + { + name: "empty delegation indexes", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{}, @@ -2471,9 +2501,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errors.New("batch undelegation must contain at least one delegation index"), }, { - name: "mismatched lengths", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "mismatched lengths", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2485,9 +2516,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errors.New("delegation indexes and amounts must have the same length"), }, { - name: "invalid validator", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "invalid validator", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2498,9 +2530,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errValidatorNotExist, }, { - name: "negative amount", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "negative amount", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2511,9 +2544,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errNegativeAmount, }, { - name: "delegation index out of bound", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "delegation index out of bound", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2524,9 +2558,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errors.New("Delegation index out of bound"), }, { - name: "delegator address mismatch", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "delegator address mismatch", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: makeTestAddr("wrong delegator"), DelegationIndexes: []staking.DelegationIndex{ @@ -2540,7 +2575,7 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndBatchUndelegateFromMsg(test.sdb, test.epoch, &test.msg) + ws, err := VerifyAndBatchUndelegateFromMsg(test.sdb, test.epoch, &test.msg, test.chainConfig) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) @@ -2667,7 +2702,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain) + ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain, nil) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) @@ -2682,3 +2717,163 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { }) } } + +func TestBatchDelegateRedelegationComposition(t *testing.T) { + epoch := big.NewInt(10) + oldEpoch := big.NewInt(5) + + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: new(big.Int).Set(fifteenKOnes), epoch: oldEpoch}, + }) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{{ + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), + BlockNum: big.NewInt(100), + }} + + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.RedelegationEpoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + // Two destinations share the same locked-token source. Composition must + // consume undelegations sequentially (not double-count from fresh copies). + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr2, Amount: new(big.Int).Set(tenKOnes)}, + }, + } + + ws, balance, fromLocked, err := VerifyAndBatchDelegateFromMsg( + sdb, epoch, &msg, delegationIndex, config, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance.Sign() != 0 { + t.Fatalf("expected fully locked-funded batch, got liquid deduct %v", balance) + } + locked, ok := fromLocked[validatorAddr] + if !ok || locked.Cmp(fifteenKOnes) != 0 { + t.Fatalf("expected 15k locked from source validator, got %v (ok=%v)", locked, ok) + } + + var sourceUndelegations staking.Undelegations + for _, wrapper := range ws { + if wrapper.Address == validatorAddr { + for _, del := range wrapper.Delegations { + if del.DelegatorAddress == delegatorAddr { + sourceUndelegations = del.Undelegations + } + } + } + } + if len(sourceUndelegations) != 0 { + t.Fatalf("expected all locked tokens consumed, remaining %+v", sourceUndelegations) + } +} + +func TestBatchDelegateSameValidatorTwice(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + } + + ws, balance, _, err := VerifyAndBatchDelegateFromMsg( + makeStateDBForStake(t), epoch, &msg, nil, config, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance.Cmp(fifteenKOnes) != 0 { + t.Fatalf("expected liquid deduct 15k, got %v", balance) + } + if len(ws) != 1 { + t.Fatalf("expected single wrapper, got %d", len(ws)) + } + found := false + for _, del := range ws[0].Delegations { + if del.DelegatorAddress == delegatorAddr { + found = true + if del.Amount.Cmp(fifteenKOnes) != 0 { + t.Fatalf("expected combined delegation 15k, got %v", del.Amount) + } + } + } + if !found { + t.Fatal("missing combined delegation") + } +} + +func TestRedelegationSkipsSameEpochUndelegation(t *testing.T) { + epoch := big.NewInt(10) + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: new(big.Int).Set(tenKOnes), epoch: epoch}, // same epoch: not eligible + }) + // Leave almost no liquid balance so same-epoch locked funds cannot silently fund the stake. + sdb.SetBalance(delegatorAddr, big.NewInt(0)) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{{ + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), + BlockNum: big.NewInt(100), + }} + + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.RedelegationEpoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + msg := staking.Delegate{ + DelegatorAddress: delegatorAddr, + ValidatorAddress: validatorAddr, + Amount: new(big.Int).Set(fiveKOnes), + } + _, _, _, err = VerifyAndDelegateFromMsg(sdb, epoch, &msg, delegationIndex, config) + if assErr := assertError(err, errInsufficientBalanceForStake); assErr != nil { + t.Fatal(assErr) + } +} + +func TestUndelegateAllRequiresStakingV2(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = big.NewInt(10000000) + + _, err := VerifyAndUndelegateAllFromMsg( + makeDefaultStateForUndelegate(t), + epoch, + &staking.UndelegateAll{DelegatorAddress: delegatorAddr}, + []staking.DelegationIndex{{ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}}, + makeFakeChainContextForStake(), + config, + ) + if assErr := assertError(err, errors.New("undelegate all is only available in StakingV2 epoch")); assErr != nil { + t.Fatal(assErr) + } +} diff --git a/core/tx_pool.go b/core/tx_pool.go index 6b8e577b9b..2711a6e06b 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -1042,7 +1042,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if from != stkMsg.DelegatorAddress { return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) } - _, err = VerifyAndBatchUndelegateFromMsg(pool.currentState, pendingEpoch, stkMsg) + _, err = VerifyAndBatchUndelegateFromMsg(pool.currentState, pendingEpoch, stkMsg, pool.chainconfig) return err case staking.DirectiveUndelegateAll: pendingEpoch := pool.pendingEpoch() @@ -1069,7 +1069,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if err != nil { return err } - _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain) + _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain, pool.chainconfig) return err default: return staking.ErrInvalidStakingKind diff --git a/core/vm/contracts.go b/core/vm/contracts.go index b8aca6b388..94fef3b22b 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -296,22 +296,27 @@ func RunPrecompiledContract(p WriteCapablePrecompiledContract, evm *EVM, contrac } gasCost, err := p.RequiredGas(evm, contract, input) if err != nil { - return wrapWritePrecompileError(evm, nil, 0, err) + return wrapWritePrecompileError(evm, p, nil, 0, err) } if suppliedGas < gasCost { return nil, 0, ErrOutOfGas } suppliedGas -= gasCost output, err := p.RunWriteCapable(evm, contract, input) - return wrapWritePrecompileError(evm, output, suppliedGas, err) + return wrapWritePrecompileError(evm, p, output, suppliedGas, err) } func wrapWritePrecompileError( - evm *EVM, output []byte, remainingGas uint64, err error, + evm *EVM, p WriteCapablePrecompiledContract, output []byte, remainingGas uint64, err error, ) ([]byte, uint64, error) { if err == nil || err == ErrExecutionReverted { return output, remainingGas, err } + // Only the staking precompile switches to ABI Error(string) reverts after StakingV2. + // Other write-capable precompiles keep their prior exceptional-error behavior. + if _, ok := p.(*stakingPrecompile); !ok { + return output, remainingGas, err + } if evm == nil || evm.ChainConfig() == nil || !evm.ChainConfig().IsStakingV2(evm.Context.EpochNumber) { return output, remainingGas, err diff --git a/core/vm/precompile_revert_test.go b/core/vm/precompile_revert_test.go index 9c2f8b87ba..460fa53920 100644 --- a/core/vm/precompile_revert_test.go +++ b/core/vm/precompile_revert_test.go @@ -23,7 +23,7 @@ func TestWrapWritePrecompileErrorBeforeFork(t *testing.T) { ) origErr := errors.New("insufficient balance to stake") - output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + output, gas, err := wrapWritePrecompileError(evm, &stakingPrecompile{}, nil, 42_000, origErr) require.Equal(t, origErr, err) require.Equal(t, uint64(42_000), gas) require.Nil(t, output) @@ -39,7 +39,7 @@ func TestWrapWritePrecompileErrorAfterFork(t *testing.T) { ) origErr := errors.New("insufficient balance to stake") - output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + output, gas, err := wrapWritePrecompileError(evm, &stakingPrecompile{}, nil, 42_000, origErr) require.ErrorIs(t, err, ErrExecutionReverted) require.Equal(t, uint64(42_000), gas) @@ -48,6 +48,22 @@ func TestWrapWritePrecompileErrorAfterFork(t *testing.T) { require.Equal(t, origErr.Error(), reason) } +func TestWrapWritePrecompileErrorSkipsNonStaking(t *testing.T) { + evm := NewEVM( + BlockContext{EpochNumber: big.NewInt(6)}, + TxContext{}, + nil, + params.LocalnetChainConfig, + Config{}, + ) + + origErr := errors.New("cross shard transfer failed") + output, gas, err := wrapWritePrecompileError(evm, &crossShardXferPrecompile{}, nil, 42_000, origErr) + require.Equal(t, origErr, err) + require.Equal(t, uint64(42_000), gas) + require.Nil(t, output) +} + func TestStakingPrecompileAddressMismatchRevertsAfterFork(t *testing.T) { env := NewEVM(BlockContext{ CollectRewards: CollectRewardsFn(), @@ -73,9 +89,13 @@ func TestStakingPrecompileAddressMismatchRevertsAfterFork(t *testing.T) { p := &stakingPrecompile{} gas, err := p.RequiredGas(env, contract, input) require.NoError(t, err) - contract.Gas = gas + contract.Gas = gas + 1_000 - _, remainingGas, err := RunPrecompiledContract(p, env, contract, input, gas, false) + output, remainingGas, err := RunPrecompiledContract(p, env, contract, input, gas+1_000, false) require.ErrorIs(t, err, ErrExecutionReverted) - require.NotZero(t, remainingGas) + require.Equal(t, uint64(1_000), remainingGas) + + reason, unpackErr := abi.UnpackRevert(output) + require.NoError(t, unpackErr) + require.NotEmpty(t, reason) } From f8b635b60e24b766b2fea419a8723a871d0bebf3 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Tue, 21 Jul 2026 19:04:41 +0800 Subject: [PATCH 08/23] fix Staking V2 malformed staking precompile input test --- core/evm_test.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/core/evm_test.go b/core/evm_test.go index e095e5917b..88eb1cac8d 100644 --- a/core/evm_test.go +++ b/core/evm_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" bls_core "github.com/harmony-one/bls/ffi/go/bls" + "github.com/harmony-one/harmony/accounts/abi" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" @@ -444,15 +445,25 @@ func TestWriteCapablePrecompilesIntegration(t *testing.T) { evm := vm.NewEVM(ctx, NewEVMTxContext(msg), db, params.TestChainConfig, vm.Config{}) // interpreter := vm.NewEVMInterpreter(evm, vm.Config{}) address := common.BytesToAddress([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252}) - // caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) - _, _, err := evm.Call(vm.AccountRef(common.Address{}), address, - []byte{109, 107, 47, 119, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19}, - math.MaxUint64, new(big.Int)) - expectedError := errors.New("abi: cannot marshal in to go type: length insufficient 31 require 32") - if err != nil { - if err.Error() != expectedError.Error() { - t.Error(fmt.Sprintf("Got error %v in evm.Call but expected %v", err, expectedError)) + malformedInput := []byte{109, 107, 47, 119, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19} + expectedABIError := "abi: cannot marshal in to go type: length insufficient 31 require 32" + ret, _, err := evm.Call(vm.AccountRef(common.Address{}), address, malformedInput, math.MaxUint64, new(big.Int)) + if err == nil { + t.Fatal("expected error from malformed staking precompile input") + } + if params.TestChainConfig.IsStakingV2(header.Epoch()) { + if !errors.Is(err, vm.ErrExecutionReverted) { + t.Errorf("Got error %v in evm.Call but expected %v", err, vm.ErrExecutionReverted) + } + reason, unpackErr := abi.UnpackRevert(ret) + if unpackErr != nil { + t.Fatalf("failed to unpack revert data: %v", unpackErr) + } + if reason != expectedABIError { + t.Errorf("Got revert reason %q but expected %q", reason, expectedABIError) } + } else if err.Error() != expectedABIError { + t.Errorf("Got error %v in evm.Call but expected %v", err, expectedABIError) } // now add a validator, and send its address as caller From c0a46a7c2b9fd37ed50f36ab7faf1d4bd288c3f0 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Tue, 21 Jul 2026 19:53:59 +0800 Subject: [PATCH 09/23] fix staking undelegation same-epoch --- core/staking_verifier.go | 14 ++++++++------ staking/types/delegation.go | 23 +++++++++-------------- staking/types/delegation_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/core/staking_verifier.go b/core/staking_verifier.go index 819b9b3834..b03ef89ad6 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -906,15 +906,17 @@ func VerifyAndMigrateFromMsg( totalAmount = delegation.Amount.Add(delegation.Amount, delegationAmountToMigrate) // and the undelegations for _, undelegationToMigrate := range undelegationsToMigrate { - exist := false - for _, entry := range delegation.Undelegations { - if entry.Epoch.Cmp(undelegationToMigrate.Epoch) == 0 { - exist = true - entry.Amount.Add(entry.Amount, undelegationToMigrate.Amount) + merged := false + for i := range delegation.Undelegations { + if delegation.Undelegations[i].Epoch.Cmp(undelegationToMigrate.Epoch) == 0 { + delegation.Undelegations[i].Amount.Add( + delegation.Undelegations[i].Amount, undelegationToMigrate.Amount, + ) + merged = true break } } - if !exist { + if !merged { delegation.Undelegations = append(delegation.Undelegations, undelegationToMigrate) } diff --git a/staking/types/delegation.go b/staking/types/delegation.go index fef7fdfbe7..8f7cbf536e 100644 --- a/staking/types/delegation.go +++ b/staking/types/delegation.go @@ -129,25 +129,20 @@ func (d *Delegation) Undelegate(epoch *big.Int, amt *big.Int) error { } d.Amount.Sub(d.Amount, amt) - exist := false - for _, entry := range d.Undelegations { - if entry.Epoch.Cmp(epoch) == 0 { - exist = true - entry.Amount.Add(entry.Amount, amt) + for i := range d.Undelegations { + if d.Undelegations[i].Epoch.Cmp(epoch) == 0 { + d.Undelegations[i].Amount.Add(d.Undelegations[i].Amount, amt) return nil } } - if !exist { - item := Undelegation{amt, epoch} - d.Undelegations = append(d.Undelegations, item) + d.Undelegations = append(d.Undelegations, Undelegation{amt, epoch}) - // Always sort the undelegate by epoch in increasing order - sort.SliceStable( - d.Undelegations, - func(i, j int) bool { return d.Undelegations[i].Epoch.Cmp(d.Undelegations[j].Epoch) < 0 }, - ) - } + // Always sort the undelegate by epoch in increasing order + sort.SliceStable( + d.Undelegations, + func(i, j int) bool { return d.Undelegations[i].Epoch.Cmp(d.Undelegations[j].Epoch) < 0 }, + ) return nil } diff --git a/staking/types/delegation_test.go b/staking/types/delegation_test.go index ff0c25c02b..7031f28d02 100644 --- a/staking/types/delegation_test.go +++ b/staking/types/delegation_test.go @@ -16,6 +16,36 @@ var ( delegation = NewDelegation(delegatorAddr, delegationAmt) ) +func TestUndelegateSameEpoch(t *testing.T) { + d := NewDelegation(delegatorAddr, big.NewInt(100000)) + epoch := big.NewInt(10) + amount1 := big.NewInt(1000) + amount2 := big.NewInt(2000) + + if err := d.Undelegate(epoch, amount1); err != nil { + t.Fatalf("first undelegate failed: %v", err) + } + if err := d.Undelegate(epoch, amount2); err != nil { + t.Fatalf("second undelegate failed: %v", err) + } + + if len(d.Undelegations) != 1 { + t.Fatalf("expected one undelegation entry, got %d", len(d.Undelegations)) + } + expectedUndelegated := big.NewInt(3000) + if d.Undelegations[0].Amount.Cmp(expectedUndelegated) != 0 { + t.Errorf("same-epoch merge: undelegation amount = %s, want %s", + d.Undelegations[0].Amount, expectedUndelegated) + } + if d.Undelegations[0].Epoch.Cmp(epoch) != 0 { + t.Errorf("undelegation epoch = %s, want %s", d.Undelegations[0].Epoch, epoch) + } + expectedDelegated := big.NewInt(97000) + if d.Amount.Cmp(expectedDelegated) != 0 { + t.Errorf("delegated amount = %s, want %s", d.Amount, expectedDelegated) + } +} + func TestUndelegate(t *testing.T) { epoch1 := big.NewInt(10) amount1 := big.NewInt(1000) From 0045ff016914a9684ab51a587cf58ea7e52cd077 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Thu, 23 Jul 2026 00:42:14 +0800 Subject: [PATCH 10/23] staking v2: reshape BatchUndelegate API and add batch gas limits --- core/evm.go | 43 +---- core/staking_verifier.go | 186 +++++++++--------- core/staking_verifier_test.go | 297 +++++++++++++++++++++++------ core/state_transition.go | 24 +++ core/tx_pool.go | 12 +- internal/params/protocol_params.go | 4 + staking/types/gas.go | 62 ++++++ staking/types/gas_test.go | 80 ++++++++ staking/types/messages.go | 60 +++--- 9 files changed, 551 insertions(+), 217 deletions(-) create mode 100644 staking/types/gas.go create mode 100644 staking/types/gas_test.go diff --git a/core/evm.go b/core/evm.go index 8578b90609..3b75ce5bdd 100644 --- a/core/evm.go +++ b/core/evm.go @@ -352,10 +352,6 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc db.SubBalance(batchDelegate.DelegatorAddress, balanceToBeDeducted) if rosettaTracer != nil && balanceToBeDeducted.Sign() != 0 { - // Attribute liquid funds to each destination proportionally is not - // available without per-action liquid splits; log the aggregate - // liquid deduction once (matches single-Delegate amount semantics - // for the total liquid spent). dest := batchDelegate.Delegations[0].ValidatorAddress rosettaTracer.AddRosettaLog( vm.CALL, @@ -379,8 +375,6 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc sort.SliceStable(sortedKeys, func(i, j int) bool { return bytes.Compare(sortedKeys[i][:], sortedKeys[j][:]) < 0 }) - // When the batch has a single destination, Rosetta can mirror - // single-Delegate logging (source undelegation -> dest delegation). var singleDest *common.Address if len(batchDelegate.Delegations) == 1 { singleDest = &batchDelegate.Delegations[0].ValidatorAddress @@ -444,18 +438,19 @@ func BatchUndelegateFn(ref *block.Header, chain ChainContext) vm.BatchUndelegate } if rosettaTracer != nil { - for i, delegationIndex := range batchUndelegate.DelegationIndexes { - amount := batchUndelegate.Amounts[i] + for _, action := range batchUndelegate.Undelegations { + amount := action.Amount + validatorAddr := action.ValidatorAddress rosettaTracer.AddRosettaLog( vm.CALL, &vm.RosettaLogAddressItem{ Account: &batchUndelegate.DelegatorAddress, - SubAccount: &delegationIndex.ValidatorAddress, + SubAccount: &validatorAddr, Metadata: map[string]interface{}{"type": "delegation"}, }, &vm.RosettaLogAddressItem{ Account: &batchUndelegate.DelegatorAddress, - SubAccount: &delegationIndex.ValidatorAddress, + SubAccount: &validatorAddr, Metadata: map[string]interface{}{"type": "undelegation"}, }, amount, @@ -477,29 +472,7 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc return err } - // Track original amounts before undelegation for rosetta logging - originalAmounts := map[common.Address]*big.Int{} - for _, delegationIndex := range delegations { - if !db.IsValidator(delegationIndex.ValidatorAddress) { - continue - } - wrapper, err := db.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) - if err != nil { - continue - } - if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { - continue - } - delegation := &wrapper.Delegations[delegationIndex.Index] - if !bytes.Equal(delegation.DelegatorAddress.Bytes(), undelegateAll.DelegatorAddress.Bytes()) { - continue - } - if delegation.Amount.Cmp(common.Big0) > 0 { - originalAmounts[delegationIndex.ValidatorAddress] = new(big.Int).Set(delegation.Amount) - } - } - - updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( + updatedValidatorWrappers, actions, err := VerifyAndUndelegateAllFromMsg( db, ref.Epoch(), undelegateAll, delegations, chain, chain.Config(), ) if err != nil { @@ -512,7 +485,9 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc } if rosettaTracer != nil { - for validatorAddr, amount := range originalAmounts { + for _, action := range actions { + validatorAddr := action.ValidatorAddress + amount := action.Amount rosettaTracer.AddRosettaLog( vm.CALL, &vm.RosettaLogAddressItem{ diff --git a/core/staking_verifier.go b/core/staking_verifier.go index b03ef89ad6..92c11a380d 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -281,10 +281,8 @@ func VerifyAndDelegateFromMsg( return verifyAndDelegateFromMsg(stateDB, epoch, msg, delegations, chainConfig, nil) } -// verifyAndDelegateFromMsg is the shared implementation for single and batch -// delegation. When wrapperCache is non-nil, wrappers are reused across calls so -// batch actions compose against the same in-memory state (undelegations, -// amounts) without mutating stateDB. +// verifyAndDelegateFromMsg implements single and batch delegation. +// When wrapperCache is non-nil, wrappers are reused across calls. func verifyAndDelegateFromMsg( stateDB vm.StateDB, epoch *big.Int, msg *staking.Delegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, wrapperCache map[common.Address]*staking.ValidatorWrapper, @@ -359,22 +357,17 @@ func verifyAndDelegateFromMsg( isStakingV2 := chainConfig.IsStakingV2(epoch) if isStakingV2 { - // Staking V2: Properly handle undelegation consumption with explicit entry removal newUndelegations := []staking.Undelegation{} for curIndex < len(delegation.Undelegations) { entry := &delegation.Undelegations[curIndex] if entry.Epoch.Cmp(epoch) >= 0 { - // Keep all remaining entries (not yet eligible for redelegation) newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) break } if entry.Amount.Cmp(delegateBalance) <= 0 { - // Fully consume this entry delegateBalance.Sub(delegateBalance, entry.Amount) - // Don't add to newUndelegations (fully consumed) } else { - // Partially consume this entry remainingAmount := big.NewInt(0).Sub(entry.Amount, delegateBalance) newUndelegations = append(newUndelegations, staking.Undelegation{ Amount: remainingAmount, @@ -382,7 +375,6 @@ func verifyAndDelegateFromMsg( }) delegateBalance = big.NewInt(0) curIndex++ - // Keep all remaining entries if curIndex < len(delegation.Undelegations) { newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) } @@ -390,12 +382,10 @@ func verifyAndDelegateFromMsg( } curIndex++ } - // Only update undelegations if something was consumed if startBalance.Cmp(delegateBalance) > 0 { delegation.Undelegations = newUndelegations } } else { - // Original logic (for backward compatibility) for ; curIndex < len(delegation.Undelegations); curIndex++ { if delegation.Undelegations[curIndex].Epoch.Cmp(epoch) >= 0 { break @@ -413,9 +403,7 @@ func verifyAndDelegateFromMsg( } if startBalance.Cmp(delegateBalance) > 0 { - // Used undelegated token for redelegation if !isStakingV2 { - // Original logic: slice undelegations array delegation.Undelegations = delegation.Undelegations[curIndex:] } if err := wrapper.SanityCheck(); err != nil { @@ -475,9 +463,8 @@ func verifyAndDelegateFromMsg( return updatedValidatorWrappers, big.NewInt(0), fromLockedTokens, nil } - // Still need to deduct tokens from balance for delegation - // Check if there is enough liquid token to delegate - if !CanTransfer(stateDB, msg.DelegatorAddress, delegateBalance) { + // Liquid balance is checked only when wrapperCache is nil. + if wrapperCache == nil && !CanTransfer(stateDB, msg.DelegatorAddress, delegateBalance) { return nil, nil, nil, errors.Wrapf( errInsufficientBalanceForStake, "totalRedelegatable: %v, balance: %v; trying to stake %v", big.NewInt(0).Sub(msg.Amount, delegateBalance), stateDB.GetBalance(msg.DelegatorAddress), msg.Amount) @@ -557,6 +544,9 @@ func VerifyAndBatchDelegateFromMsg( if len(msg.Delegations) == 0 { return nil, nil, nil, errors.New("batch delegation must contain at least one delegation") } + if len(msg.Delegations) > staking.MaxBatchStakingActions { + return nil, nil, nil, staking.ErrBatchTooLarge + } wrapperCache := map[common.Address]*staking.ValidatorWrapper{} totalBalanceToDeduct := big.NewInt(0) @@ -612,23 +602,37 @@ func VerifyAndBatchDelegateFromMsg( } } - // Preserve stable insertion order from first touch in the cache. + return sortedWrappersFromCache(wrapperCache, msg.Delegations), totalBalanceToDeduct, allFromLockedTokens, nil +} + +// sortedWrappersFromCache returns wrappers for action destinations in order, +// then any remaining cached addresses sorted by address. +func sortedWrappersFromCache( + wrapperCache map[common.Address]*staking.ValidatorWrapper, + actions []staking.DelegationAction, +) []*staking.ValidatorWrapper { allUpdatedWrappers := make([]*staking.ValidatorWrapper, 0, len(wrapperCache)) seen := map[common.Address]bool{} - for _, delegationAction := range msg.Delegations { - if w, ok := wrapperCache[delegationAction.ValidatorAddress]; ok && !seen[w.Address] { + for _, action := range actions { + addr := action.ValidatorAddress + if w, ok := wrapperCache[addr]; ok && !seen[addr] { allUpdatedWrappers = append(allUpdatedWrappers, w) - seen[w.Address] = true + seen[addr] = true } } - for addr, w := range wrapperCache { + rest := make([]common.Address, 0, len(wrapperCache)) + for addr := range wrapperCache { if !seen[addr] { - allUpdatedWrappers = append(allUpdatedWrappers, w) - seen[addr] = true + rest = append(rest, addr) } } - - return allUpdatedWrappers, totalBalanceToDeduct, allFromLockedTokens, nil + sort.Slice(rest, func(i, j int) bool { + return bytes.Compare(rest[i][:], rest[j][:]) < 0 + }) + for _, addr := range rest { + allUpdatedWrappers = append(allUpdatedWrappers, wrapperCache[addr]) + } + return allUpdatedWrappers } // VerifyAndBatchUndelegateFromMsg verifies batch undelegation message using the stateDB @@ -650,57 +654,64 @@ func VerifyAndBatchUndelegateFromMsg( if !chainConfig.IsStakingV2(epoch) { return nil, errors.New("batch undelegation is only available in StakingV2 epoch") } - if len(msg.DelegationIndexes) == 0 { - return nil, errors.New("batch undelegation must contain at least one delegation index") + if len(msg.Undelegations) == 0 { + return nil, errors.New("batch undelegation must contain at least one undelegation") } - if len(msg.DelegationIndexes) != len(msg.Amounts) { - return nil, errors.New("delegation indexes and amounts must have the same length") + if len(msg.Undelegations) > staking.MaxBatchStakingActions { + return nil, staking.ErrBatchTooLarge } - allUpdatedWrappers := []*staking.ValidatorWrapper{} wrapperMap := map[common.Address]*staking.ValidatorWrapper{} + touched := []common.Address{} - for i, delegationIndex := range msg.DelegationIndexes { - amount := msg.Amounts[i] - if amount == nil || amount.Sign() == -1 { + for _, action := range msg.Undelegations { + amount := action.Amount + if amount == nil || amount.Sign() < 0 { return nil, errNegativeAmount } + if amount.Sign() == 0 { + return nil, errors.New("invalid amount, must be positive") + } - if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { + if !stateDB.IsValidator(action.ValidatorAddress) { return nil, errValidatorNotExist } - var wrapper *staking.ValidatorWrapper - var exists bool - if wrapper, exists = wrapperMap[delegationIndex.ValidatorAddress]; !exists { + wrapper, exists := wrapperMap[action.ValidatorAddress] + if !exists { var err error - wrapper, err = stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, true) + wrapper, err = stateDB.ValidatorWrapper(action.ValidatorAddress, false, true) if err != nil { return nil, err } - wrapperMap[delegationIndex.ValidatorAddress] = wrapper - } - - if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { - utils.Logger().Warn(). - Str("validator", delegationIndex.ValidatorAddress.String()). - Uint64("delegation index", delegationIndex.Index). - Int("delegations length", len(wrapper.Delegations)). - Msg("Delegation index out of bound") - return nil, errors.New("Delegation index out of bound") + if err := checkValidatorWrapperAddressBinding( + chainConfig, epoch, action.ValidatorAddress, wrapper, + ); err != nil { + return nil, err + } + wrapperMap[action.ValidatorAddress] = wrapper + touched = append(touched, action.ValidatorAddress) } - delegation := &wrapper.Delegations[delegationIndex.Index] - if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { - return nil, errors.New("delegator address mismatch") + found := false + for i := range wrapper.Delegations { + delegation := &wrapper.Delegations[i] + if bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + if err := delegation.Undelegate(epoch, amount); err != nil { + return nil, err + } + found = true + break + } } - - if err := delegation.Undelegate(epoch, amount); err != nil { - return nil, err + if !found { + return nil, errNoDelegationToUndelegate } } - for _, wrapper := range wrapperMap { + allUpdatedWrappers := make([]*staking.ValidatorWrapper, 0, len(touched)) + for _, addr := range touched { + wrapper := wrapperMap[addr] if err := wrapper.SanityCheck(); err != nil { if errors.Cause(err) == staking.ErrInvalidSelfDelegation { wrapper.Status = effective.Inactive @@ -714,19 +725,19 @@ func VerifyAndBatchUndelegateFromMsg( return allUpdatedWrappers, nil } -// VerifyAndUndelegateAllFromMsg verifies and prepares undelegation of all delegations -// for a delegator. It reads all delegations from the current state and creates a batch undelegation. -// This ensures delegations created in the same block are included. +// VerifyAndUndelegateAllFromMsg undelegates all active stake for a delegator. +// It uses the provided delegation indexes and scans validators for any others. // // Note that this function never updates the stateDB, it only reads from stateDB. +// The returned UndelegationAction slice lists every undelegation applied. func VerifyAndUndelegateAllFromMsg( stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, chainConfig *params.ChainConfig, -) ([]*staking.ValidatorWrapper, error) { +) ([]*staking.ValidatorWrapper, []staking.UndelegationAction, error) { if stateDB == nil { - return nil, errStateDBIsMissing + return nil, nil, errStateDBIsMissing } if epoch == nil { - return nil, errEpochMissing + return nil, nil, errEpochMissing } if chainConfig == nil { if chainContext != nil { @@ -734,17 +745,16 @@ func VerifyAndUndelegateAllFromMsg( } } if chainConfig == nil { - return nil, errors.New("chain config is required") + return nil, nil, errors.New("chain config is required") } if !chainConfig.IsStakingV2(epoch) { - return nil, errors.New("undelegate all is only available in StakingV2 epoch") + return nil, nil, errors.New("undelegate all is only available in StakingV2 epoch") } - delegationIndexes := []staking.DelegationIndex{} - amounts := []*big.Int{} + actions := []staking.UndelegationAction{} processedValidators := map[common.Address]map[uint64]bool{} - // First, process delegations from the provided list (from previous block) + // Process delegations from the provided index list. for _, delegationIndex := range delegations { if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { continue @@ -768,17 +778,18 @@ func VerifyAndUndelegateAllFromMsg( continue } - delegationIndexes = append(delegationIndexes, delegationIndex) - amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + actions = append(actions, staking.UndelegationAction{ + ValidatorAddress: delegationIndex.ValidatorAddress, + Amount: new(big.Int).Set(delegation.Amount), + }) - // Track processed delegations to avoid duplicates if processedValidators[delegationIndex.ValidatorAddress] == nil { processedValidators[delegationIndex.ValidatorAddress] = make(map[uint64]bool) } processedValidators[delegationIndex.ValidatorAddress][delegationIndex.Index] = true } - // Then, scan all validators in current state to find any new delegations created in this block + // Scan validators for active delegations not already processed. if chainContext != nil { validatorList, err := chainContext.ReadValidatorList() if err == nil { @@ -792,7 +803,6 @@ func VerifyAndUndelegateAllFromMsg( continue } - // Check all delegations for this delegator for i := range wrapper.Delegations { delegation := &wrapper.Delegations[i] if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { @@ -803,18 +813,14 @@ func VerifyAndUndelegateAllFromMsg( continue } - // Skip if already processed if processedValidators[validatorAddr] != nil && processedValidators[validatorAddr][uint64(i)] { continue } - // Found a new delegation (created in this block) - delegationIndexes = append(delegationIndexes, staking.DelegationIndex{ + actions = append(actions, staking.UndelegationAction{ ValidatorAddress: validatorAddr, - Index: uint64(i), - BlockNum: big.NewInt(0), + Amount: new(big.Int).Set(delegation.Amount), }) - amounts = append(amounts, new(big.Int).Set(delegation.Amount)) if processedValidators[validatorAddr] == nil { processedValidators[validatorAddr] = make(map[uint64]bool) @@ -825,21 +831,29 @@ func VerifyAndUndelegateAllFromMsg( } } - // If no delegations found and no chain context to scan, return error - if len(delegationIndexes) == 0 { + if len(actions) == 0 { if chainContext == nil && len(delegations) == 0 { - return nil, errors.New("no delegations to undelegate") + return nil, nil, errors.New("no delegations to undelegate") } - return nil, errors.New("no active delegations to undelegate") + return nil, nil, errors.New("no active delegations to undelegate") + } + if len(actions) > staking.MaxBatchStakingActions { + return nil, nil, errors.Errorf( + "undelegate all has %d active delegations; max is %d", + len(actions), staking.MaxBatchStakingActions, + ) } batchUndelegateMsg := &staking.BatchUndelegate{ - DelegatorAddress: msg.DelegatorAddress, - DelegationIndexes: delegationIndexes, - Amounts: amounts, + DelegatorAddress: msg.DelegatorAddress, + Undelegations: actions, } - return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg, chainConfig) + wrappers, err := VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg, chainConfig) + if err != nil { + return nil, nil, err + } + return wrappers, actions, nil } // VerifyAndMigrateFromMsg verifies and transfers all delegations of diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 0f3f52fc53..9df1626c34 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -2408,12 +2408,13 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { } tests := []struct { - name string - sdb vm.StateDB - epoch *big.Int - msg staking.BatchUndelegate - chainConfig *params.ChainConfig - expErr error + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchUndelegate + chainConfig *params.ChainConfig + expErr error + checkAmounts bool }{ { name: "successful batch undelegate from two validators", @@ -2432,15 +2433,12 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, - {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, - }, - Amounts: []*big.Int{ - new(big.Int).Set(fiveKOnes), - new(big.Int).Set(fiveKOnes), + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr2, Amount: new(big.Int).Set(fiveKOnes)}, }, }, + checkAmounts: true, }, { name: "nil state db", @@ -2449,10 +2447,9 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errStateDBIsMissing, }, @@ -2463,10 +2460,9 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errEpochMissing, }, @@ -2481,39 +2477,22 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { }(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errors.New("batch undelegation is only available in StakingV2 epoch"), }, { - name: "empty delegation indexes", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, - chainConfig: stakingV2Config(), - msg: staking.BatchUndelegate{ - DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{}, - Amounts: []*big.Int{}, - }, - expErr: errors.New("batch undelegation must contain at least one delegation index"), - }, - { - name: "mismatched lengths", + name: "empty undelegations", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, - {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, - }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + Undelegations: []staking.UndelegationAction{}, }, - expErr: errors.New("delegation indexes and amounts must have the same length"), + expErr: errors.New("batch undelegation must contain at least one undelegation"), }, { name: "invalid validator", @@ -2522,10 +2501,9 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: makeTestAddr("not exist"), Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: makeTestAddr("not exist"), Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errValidatorNotExist, }, @@ -2536,40 +2514,37 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: big.NewInt(-1)}, }, - Amounts: []*big.Int{big.NewInt(-1)}, }, expErr: errNegativeAmount, }, { - name: "delegation index out of bound", + name: "no delegation for delegator", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ - DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 999, BlockNum: big.NewInt(100)}, + DelegatorAddress: makeTestAddr("wrong delegator"), + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, - expErr: errors.New("Delegation index out of bound"), + expErr: errNoDelegationToUndelegate, }, { - name: "delegator address mismatch", + name: "insufficient stake", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ - DelegatorAddress: makeTestAddr("wrong delegator"), - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + DelegatorAddress: delegatorAddr, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(hundredKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, - expErr: errors.New("delegator address mismatch"), + expErr: errors.New("insufficient balance to undelegate"), }, } @@ -2587,6 +2562,33 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { if len(ws) == 0 { t.Errorf("Test %v: expected at least one wrapper", i) } + if test.checkAmounts { + for _, wrapper := range ws { + for _, del := range wrapper.Delegations { + if del.DelegatorAddress != delegatorAddr { + continue + } + if wrapper.Address == validatorAddr { + // started 20k, prior 5k undelegation left 15k, then another 5k -> 10k active + if del.Amount.Cmp(tenKOnes) != 0 { + t.Errorf("validator1 amount = %s, want 10k", del.Amount) + } + // prior same-epoch entry 5k + new 5k = 10k + if len(del.Undelegations) != 1 || del.Undelegations[0].Amount.Cmp(tenKOnes) != 0 { + t.Errorf("validator1 undelegation = %+v, want 10k single entry", del.Undelegations) + } + } + if wrapper.Address == validatorAddr2 { + if del.Amount.Cmp(fifteenKOnes) != 0 { + t.Errorf("validator2 amount = %s, want 15k", del.Amount) + } + if len(del.Undelegations) != 1 || del.Undelegations[0].Amount.Cmp(fiveKOnes) != 0 { + t.Errorf("validator2 undelegation = %+v, want 5k", del.Undelegations) + } + } + } + } + } }) } } @@ -2702,7 +2704,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain, nil) + ws, actions, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain, nil) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) @@ -2714,6 +2716,9 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { if len(ws) == 0 { t.Errorf("Test %v: expected at least one wrapper", i) } + if len(actions) == 0 { + t.Errorf("Test %v: expected undelegation actions for Rosetta", i) + } }) } } @@ -2744,8 +2749,6 @@ func TestBatchDelegateRedelegationComposition(t *testing.T) { config.RedelegationEpoch = epoch config.MinDelegation100Epoch = big.NewInt(100) - // Two destinations share the same locked-token source. Composition must - // consume undelegations sequentially (not double-count from fresh copies). msg := staking.BatchDelegate{ DelegatorAddress: delegatorAddr, Delegations: []staking.DelegationAction{ @@ -2865,7 +2868,7 @@ func TestUndelegateAllRequiresStakingV2(t *testing.T) { config := ¶ms.ChainConfig{} config.StakingV2Epoch = big.NewInt(10000000) - _, err := VerifyAndUndelegateAllFromMsg( + _, _, err := VerifyAndUndelegateAllFromMsg( makeDefaultStateForUndelegate(t), epoch, &staking.UndelegateAll{DelegatorAddress: delegatorAddr}, @@ -2877,3 +2880,171 @@ func TestUndelegateAllRequiresStakingV2(t *testing.T) { t.Fatal(assErr) } } + +func TestBatchUndelegateSameValidatorTwice(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + + msg := staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + }, + } + ws, err := VerifyAndBatchUndelegateFromMsg(makeDefaultStateForUndelegate(t), epoch, &msg, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ws) != 1 { + t.Fatalf("expected 1 wrapper, got %d", len(ws)) + } + for _, del := range ws[0].Delegations { + if del.DelegatorAddress != delegatorAddr { + continue + } + // 20k - 5k prior - 5k - 5k = 5k active; undelegation 5k+5k+5k = 15k + if del.Amount.Cmp(fiveKOnes) != 0 { + t.Fatalf("active amount = %s, want 5k", del.Amount) + } + if len(del.Undelegations) != 1 || del.Undelegations[0].Amount.Cmp(fifteenKOnes) != 0 { + t.Fatalf("undelegation = %+v, want 15k merged", del.Undelegations) + } + } +} + +func TestBatchUndelegateSelfDelegationInactive(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + + msg := staking.BatchUndelegate{ + DelegatorAddress: validatorAddr, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fifteenKOnes)}, + }, + } + ws, err := VerifyAndBatchUndelegateFromMsg(makeDefaultStateForUndelegate(t), epoch, &msg, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ws) != 1 { + t.Fatalf("expected 1 wrapper, got %d", len(ws)) + } + if ws[0].Status != effective.Inactive { + t.Fatalf("expected Inactive status, got %v", ws[0].Status) + } +} + +func TestBatchDelegateTooLarge(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + actions := make([]staking.DelegationAction, staking.MaxBatchStakingActions+1) + for i := range actions { + actions[i] = staking.DelegationAction{ + ValidatorAddress: validatorAddr, + Amount: new(big.Int).Set(fiveKOnes), + } + } + msg := staking.BatchDelegate{DelegatorAddress: delegatorAddr, Delegations: actions} + _, _, _, err := VerifyAndBatchDelegateFromMsg(makeStateDBForStake(t), epoch, &msg, nil, config) + if assErr := assertError(err, staking.ErrBatchTooLarge); assErr != nil { + t.Fatal(assErr) + } +} + +func TestBatchUndelegateTooLarge(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + + actions := make([]staking.UndelegationAction, staking.MaxBatchStakingActions+1) + for i := range actions { + actions[i] = staking.UndelegationAction{ + ValidatorAddress: validatorAddr, + Amount: big.NewInt(1), + } + } + msg := staking.BatchUndelegate{DelegatorAddress: delegatorAddr, Undelegations: actions} + _, err := VerifyAndBatchUndelegateFromMsg(makeDefaultStateForUndelegate(t), epoch, &msg, config) + if assErr := assertError(err, staking.ErrBatchTooLarge); assErr != nil { + t.Fatal(assErr) + } +} + +func TestBatchDelegateMaxTotalDelegation(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + sdb := makeStateDBForStake(t) + w, err := sdb.ValidatorWrapper(validatorAddr, false, true) + if err != nil { + t.Fatal(err) + } + current := w.TotalDelegation() + w.MaxTotalDelegation = new(big.Int).Add(current, fiveKOnes) + if err := sdb.UpdateValidatorWrapper(validatorAddr, w); err != nil { + t.Fatal(err) + } + sdb.IntermediateRoot(true) + + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + } + _, _, _, err = VerifyAndBatchDelegateFromMsg(sdb, epoch, &msg, nil, config) + if err == nil { + t.Fatal("expected max total delegation error") + } +} + +func TestBatchDelegateMixedLiquidAndLocked(t *testing.T) { + epoch := big.NewInt(10) + oldEpoch := big.NewInt(5) + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: new(big.Int).Set(fiveKOnes), epoch: oldEpoch}, + }) + sdb.SetBalance(delegatorAddr, new(big.Int).Set(fiveKOnes)) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{{ + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), + BlockNum: big.NewInt(100), + }} + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.RedelegationEpoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, // 5k locked + 5k liquid + }, + } + _, balance, fromLocked, err := VerifyAndBatchDelegateFromMsg(sdb, epoch, &msg, delegationIndex, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance.Cmp(fiveKOnes) != 0 { + t.Fatalf("liquid deduct = %s, want 5k", balance) + } + if fromLocked[validatorAddr].Cmp(fiveKOnes) != 0 { + t.Fatalf("locked used = %s, want 5k", fromLocked[validatorAddr]) + } +} diff --git a/core/state_transition.go b/core/state_transition.go index b688526d2a..8927ab2111 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -18,6 +18,7 @@ package core import ( "fmt" + "math" "math/big" "github.com/ethereum/go-ethereum/common" @@ -352,6 +353,29 @@ func (st *StateTransition) StakingTransitionDb() (usedGas uint64, err error) { if err != nil { return 0, err } + var batchDirective stakingTypes.Directive + hasBatchExtraGas := false + switch msg.Type() { + case types.BatchDelegate: + batchDirective = stakingTypes.DirectiveBatchDelegate + hasBatchExtraGas = true + case types.BatchUndelegate: + batchDirective = stakingTypes.DirectiveBatchUndelegate + hasBatchExtraGas = true + case types.UndelegateAll: + batchDirective = stakingTypes.DirectiveUndelegateAll + hasBatchExtraGas = true + } + if hasBatchExtraGas { + extra, extraErr := stakingTypes.ExtraGasForStakingDirective(batchDirective, st.data) + if extraErr != nil { + return 0, extraErr + } + if gas > math.MaxUint64-extra { + return 0, vm.ErrGasUintOverflow + } + gas += extra + } if err = st.useGas(gas); err != nil { return 0, err } diff --git a/core/tx_pool.go b/core/tx_pool.go index 2711a6e06b..b1cabd929d 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -770,6 +770,16 @@ func (pool *TxPool) validateTx(tx types.PoolTransaction, local bool) error { intrGas := uint64(0) if isStakingTx { intrGas, err = vm.IntrinsicGas(tx.Data(), false, pool.homestead, pool.istanbul, stakingTx.StakingType() == staking.DirectiveCreateValidator, pool.isEIP3860) + if err == nil { + extra, extraErr := staking.ExtraGasForStakingDirective(stakingTx.StakingType(), tx.Data()) + if extraErr != nil { + return extraErr + } + if intrGas > ^uint64(0)-extra { + return errors.New("staking batch gas overflow") + } + intrGas += extra + } } else { intrGas, err = vm.IntrinsicGas(tx.Data(), tx.To() == nil, pool.homestead, pool.istanbul, false, pool.isEIP3860) } @@ -1069,7 +1079,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if err != nil { return err } - _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain, pool.chainconfig) + _, _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain, pool.chainconfig) return err default: return staking.ErrInvalidStakingKind diff --git a/internal/params/protocol_params.go b/internal/params/protocol_params.go index f79b2367d3..c54dc629dd 100644 --- a/internal/params/protocol_params.go +++ b/internal/params/protocol_params.go @@ -34,6 +34,10 @@ const ( TxGasContractCreation uint64 = 53000 // Per transaction that creates a contract. NOTE: Not payable on data of calls between transactions. // TxGasValidatorCreation ... TxGasValidatorCreation uint64 = 5300000 // Per transaction that creates a new validator. NOTE: Not payable on data of calls between transactions. + // TxGasPerBatchStakingAction is charged per BatchDelegate / BatchUndelegate action. + TxGasPerBatchStakingAction uint64 = 25000 + // TxGasUndelegateAll is charged for UndelegateAll. + TxGasUndelegateAll uint64 = 500000 // TxDataZeroGas ... TxDataZeroGas uint64 = 4 // Per byte of data attached to a transaction that equals zero. NOTE: Not payable on data of calls between transactions. // QuadCoeffDiv ... diff --git a/staking/types/gas.go b/staking/types/gas.go new file mode 100644 index 0000000000..5b00cde16b --- /dev/null +++ b/staking/types/gas.go @@ -0,0 +1,62 @@ +package types + +import ( + "math" + + "github.com/harmony-one/harmony/internal/params" + "github.com/pkg/errors" +) + +var ( + // ErrBatchTooLarge is returned when a batch staking tx exceeds MaxBatchStakingActions. + ErrBatchTooLarge = errors.New("batch staking action count exceeds maximum") +) + +// ExtraGasForStakingDirective returns gas for batch staking directives. +// data is the RLP-encoded stake message payload. +func ExtraGasForStakingDirective(directive Directive, data []byte) (uint64, error) { + switch directive { + case DirectiveBatchDelegate: + msg, err := RLPDecodeStakeMsg(data, DirectiveBatchDelegate) + if err != nil { + return 0, err + } + batch, ok := msg.(*BatchDelegate) + if !ok { + return 0, ErrInvalidStakingKind + } + n := len(batch.Delegations) + if n > MaxBatchStakingActions { + return 0, ErrBatchTooLarge + } + return mulGas(uint64(n), params.TxGasPerBatchStakingAction) + case DirectiveBatchUndelegate: + msg, err := RLPDecodeStakeMsg(data, DirectiveBatchUndelegate) + if err != nil { + return 0, err + } + batch, ok := msg.(*BatchUndelegate) + if !ok { + return 0, ErrInvalidStakingKind + } + n := len(batch.Undelegations) + if n > MaxBatchStakingActions { + return 0, ErrBatchTooLarge + } + return mulGas(uint64(n), params.TxGasPerBatchStakingAction) + case DirectiveUndelegateAll: + return params.TxGasUndelegateAll, nil + default: + return 0, nil + } +} + +func mulGas(count, per uint64) (uint64, error) { + if count == 0 { + return 0, nil + } + if per != 0 && count > math.MaxUint64/per { + return 0, errors.New("staking batch gas overflow") + } + return count * per, nil +} diff --git a/staking/types/gas_test.go b/staking/types/gas_test.go new file mode 100644 index 0000000000..c3d49a90f7 --- /dev/null +++ b/staking/types/gas_test.go @@ -0,0 +1,80 @@ +package types + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/harmony-one/harmony/internal/params" +) + +func TestExtraGasForStakingDirective(t *testing.T) { + delegator := common.HexToAddress("0x1") + validator := common.HexToAddress("0x2") + + batchDelegate := BatchDelegate{ + DelegatorAddress: delegator, + Delegations: []DelegationAction{ + {ValidatorAddress: validator, Amount: big.NewInt(1000)}, + {ValidatorAddress: validator, Amount: big.NewInt(2000)}, + }, + } + data, err := rlp.EncodeToBytes(batchDelegate) + if err != nil { + t.Fatal(err) + } + gas, err := ExtraGasForStakingDirective(DirectiveBatchDelegate, data) + if err != nil { + t.Fatal(err) + } + want := 2 * params.TxGasPerBatchStakingAction + if gas != want { + t.Fatalf("batch delegate gas = %d, want %d", gas, want) + } + + batchUndelegate := BatchUndelegate{ + DelegatorAddress: delegator, + Undelegations: []UndelegationAction{ + {ValidatorAddress: validator, Amount: big.NewInt(1000)}, + }, + } + data, err = rlp.EncodeToBytes(batchUndelegate) + if err != nil { + t.Fatal(err) + } + gas, err = ExtraGasForStakingDirective(DirectiveBatchUndelegate, data) + if err != nil { + t.Fatal(err) + } + if gas != params.TxGasPerBatchStakingAction { + t.Fatalf("batch undelegate gas = %d, want %d", gas, params.TxGasPerBatchStakingAction) + } + + gas, err = ExtraGasForStakingDirective(DirectiveUndelegateAll, nil) + if err != nil { + t.Fatal(err) + } + if gas != params.TxGasUndelegateAll { + t.Fatalf("undelegate all gas = %d, want %d", gas, params.TxGasUndelegateAll) + } + + tooLarge := BatchDelegate{ + DelegatorAddress: delegator, + Delegations: make([]DelegationAction, MaxBatchStakingActions+1), + } + for i := range tooLarge.Delegations { + tooLarge.Delegations[i] = DelegationAction{ + ValidatorAddress: validator, + Amount: big.NewInt(1), + } + } + data, err = rlp.EncodeToBytes(tooLarge) + if err != nil { + t.Fatal(err) + } + _, err = ExtraGasForStakingDirective(DirectiveBatchDelegate, data) + if err != ErrBatchTooLarge { + t.Fatalf("expected ErrBatchTooLarge, got %v", err) + } +} diff --git a/staking/types/messages.go b/staking/types/messages.go index 1102f83868..aa4ef232cf 100644 --- a/staking/types/messages.go +++ b/staking/types/messages.go @@ -332,11 +332,20 @@ func (v BatchDelegate) Equals(s BatchDelegate) bool { return true } +// MaxBatchStakingActions is the maximum number of actions allowed in a single +// BatchDelegate or BatchUndelegate transaction. +const MaxBatchStakingActions = 50 + +// UndelegationAction represents a single undelegation action in a batch operation +type UndelegationAction struct { + ValidatorAddress common.Address `json:"validator_address"` + Amount *big.Int `json:"amount"` +} + // BatchUndelegate - type for undelegating from multiple validators in one transaction type BatchUndelegate struct { - DelegatorAddress common.Address `json:"delegator_address"` - DelegationIndexes []DelegationIndex `json:"delegation_indexes"` - Amounts []*big.Int `json:"amounts"` + DelegatorAddress common.Address `json:"delegator_address"` + Undelegations []UndelegationAction `json:"undelegations"` } // Type of BatchUndelegate @@ -347,22 +356,15 @@ func (v BatchUndelegate) Type() Directive { // Copy returns a deep copy of the BatchUndelegate as a StakeMsg interface func (v BatchUndelegate) Copy() StakeMsg { cp := BatchUndelegate{ - DelegatorAddress: v.DelegatorAddress, - DelegationIndexes: make([]DelegationIndex, len(v.DelegationIndexes)), - Amounts: make([]*big.Int, len(v.Amounts)), - } - for i, idx := range v.DelegationIndexes { - cp.DelegationIndexes[i] = DelegationIndex{ - ValidatorAddress: idx.ValidatorAddress, - Index: idx.Index, - } - if idx.BlockNum != nil { - cp.DelegationIndexes[i].BlockNum = new(big.Int).Set(idx.BlockNum) - } + DelegatorAddress: v.DelegatorAddress, + Undelegations: make([]UndelegationAction, len(v.Undelegations)), } - for i, amt := range v.Amounts { - if amt != nil { - cp.Amounts[i] = new(big.Int).Set(amt) + for i, u := range v.Undelegations { + cp.Undelegations[i] = UndelegationAction{ + ValidatorAddress: u.ValidatorAddress, + } + if u.Amount != nil { + cp.Undelegations[i].Amount = new(big.Int).Set(u.Amount) } } return cp @@ -373,28 +375,20 @@ func (v BatchUndelegate) Equals(s BatchUndelegate) bool { if !bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) { return false } - if len(v.DelegationIndexes) != len(s.DelegationIndexes) { - return false - } - if len(v.Amounts) != len(s.Amounts) { + if len(v.Undelegations) != len(s.Undelegations) { return false } - for i := range v.DelegationIndexes { - if !bytes.Equal(v.DelegationIndexes[i].ValidatorAddress.Bytes(), s.DelegationIndexes[i].ValidatorAddress.Bytes()) { + for i := range v.Undelegations { + if !bytes.Equal(v.Undelegations[i].ValidatorAddress.Bytes(), s.Undelegations[i].ValidatorAddress.Bytes()) { return false } - if v.DelegationIndexes[i].Index != s.DelegationIndexes[i].Index { - return false - } - } - for i := range v.Amounts { - if v.Amounts[i] == nil { - if s.Amounts[i] != nil { + if v.Undelegations[i].Amount == nil { + if s.Undelegations[i].Amount != nil { return false } - } else if s.Amounts[i] == nil { + } else if s.Undelegations[i].Amount == nil { return false - } else if v.Amounts[i].Cmp(s.Amounts[i]) != 0 { + } else if v.Undelegations[i].Amount.Cmp(s.Undelegations[i].Amount) != 0 { return false } } From c3d055aba76cbf521da4b750cc37448aab3ea1f9 Mon Sep 17 00:00:00 2001 From: Frozen <355847+Frozen@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:29:57 -0400 Subject: [PATCH 11/23] Replace Harmony BLS forks with official Herumi package (#5086) Replace Harmony BLS forks with official Herumi package (#5086) --- .github/workflows/ci-pr.yaml | 29 --- .github/workflows/ci-release.yaml | 18 -- Dockerfile | 11 - Makefile | 16 +- README.md | 52 +---- Test.Dockerfile | 8 +- cmd/harmony/main.go | 2 +- consensus/beacon_header_slash_test.go | 2 +- consensus/checks.go | 2 +- consensus/consensus.go | 2 +- consensus/consensus_service.go | 2 +- consensus/consensus_v2.go | 2 +- consensus/construct.go | 2 +- consensus/construct_test.go | 2 +- ...osslink_cache_invalid_shard_reward_test.go | 2 +- consensus/double_sign.go | 2 +- consensus/fbft_log.go | 2 +- consensus/leader.go | 2 +- consensus/quorum/one-node-one-vote.go | 2 +- consensus/quorum/one-node-staked-vote.go | 2 +- consensus/quorum/one-node-staked-vote_test.go | 6 +- consensus/quorum/quorom_test.go | 2 +- consensus/quorum/quorum.go | 2 +- consensus/quorum/thread_safe_decider.go | 2 +- consensus/slash_group_test.go | 2 +- consensus/validator_wrapper_payout_test.go | 2 +- consensus/view_change_construct.go | 2 +- consensus/view_change_msg.go | 2 +- consensus/view_change_test.go | 2 +- consensus/votepower/roster.go | 2 +- consensus/votepower/roster_test.go | 6 +- core/blockchain_impl.go | 2 +- core/evm_test.go | 2 +- core/rawdb/accessors_indexes_test.go | 2 +- core/tx_pool_test.go | 2 +- crypto/bls/bls.go | 2 +- crypto/bls/core/core.go | 190 ++++++++++++++++++ crypto/bls/core/core_test.go | 37 ++++ crypto/bls/mask.go | 7 +- crypto/bls/mask_test.go | 2 +- crypto/vrf/bls/bls_vrf.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/blsgen/helper.go | 2 +- internal/blsgen/helper_test.go | 2 +- internal/blsgen/kms.go | 2 +- internal/blsgen/kms_test.go | 2 +- internal/blsgen/lib.go | 2 +- internal/blsgen/loader.go | 2 +- internal/blsgen/passphrase.go | 2 +- internal/blsgen/utils.go | 2 +- internal/chain/engine.go | 2 +- internal/chain/engine_test.go | 2 +- internal/chain/sig.go | 2 +- internal/configs/node/config.go | 2 +- internal/configs/node/config_test.go | 2 +- internal/genesis/genesis_test.go | 2 +- internal/registry/registry.go | 2 +- internal/utils/utils.go | 2 +- multibls/multibls.go | 2 +- node/harmony/addresses.go | 2 +- node/harmony/node_cross_link.go | 2 +- node/harmony/node_test.go | 2 +- .../worker/slash_duplicate_reporter_test.go | 2 +- p2p/host.go | 8 +- rosetta/infra/Dockerfile | 4 +- scripts/go_executable_build.sh | 15 +- scripts/macos_docker/Dockerfile | 16 -- scripts/setup_bls_build_flags.sh | 44 ---- scripts/travis_go_checker.sh | 2 - shard/committee/assignment.go | 2 +- staking/effective/calculate_test.go | 2 +- staking/slash/double-sign.go | 2 +- staking/slash/double-sign_test.go | 4 +- staking/types/transaction_test.go | 2 +- staking/types/validator.go | 2 +- test/chain/reward/main.go | 2 +- test/chain/vrf/main.go | 2 +- test/deploy.sh | 2 - test/deploy_newnode.sh | 2 - test/helpers/p2p.go | 2 +- 81 files changed, 324 insertions(+), 277 deletions(-) create mode 100644 crypto/bls/core/core.go create mode 100644 crypto/bls/core/core_test.go delete mode 100644 scripts/setup_bls_build_flags.sh diff --git a/.github/workflows/ci-pr.yaml b/.github/workflows/ci-pr.yaml index 7e2069955f..c593851295 100644 --- a/.github/workflows/ci-pr.yaml +++ b/.github/workflows/ci-pr.yaml @@ -40,26 +40,6 @@ jobs: path: go/src/github.com/harmony-one/harmony persist-credentials: false - - &checkout-mcl - name: Checkout mcl - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 - with: - repository: harmony-one/mcl - fetch-depth: 1 - ref: master - path: go/src/github.com/harmony-one/mcl - persist-credentials: false - - - &checkout-bls - name: Checkout bls - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 - with: - repository: harmony-one/bls - fetch-depth: 1 - ref: master - path: go/src/github.com/harmony-one/bls - persist-credentials: false - - &setup-go name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 #v6.5.0 @@ -90,15 +70,7 @@ jobs: strategy: *arch-matrix steps: - *checkout-harmony - - *checkout-mcl - - *checkout-bls - *setup-go - - name: Build mcl - run: make -j4 - working-directory: go/src/github.com/harmony-one/mcl - - name: Build bls - run: make BLS_SWAP_G=1 -j4 - working-directory: go/src/github.com/harmony-one/bls - name: Install tools run: | go install golang.org/x/tools/cmd/goimports@v0.30.0 @@ -163,7 +135,6 @@ jobs: strategy: *arch-matrix steps: - *checkout-harmony - - *checkout-bls - *download-harmony-binaries - *prepare-harmony-binaries - *checkout-harmony-test diff --git a/.github/workflows/ci-release.yaml b/.github/workflows/ci-release.yaml index 9dbaed2a0b..fad3a6e8ec 100644 --- a/.github/workflows/ci-release.yaml +++ b/.github/workflows/ci-release.yaml @@ -83,24 +83,6 @@ jobs: - ubuntu-24.04-arm steps: - - name: Checkout mcl - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 - with: - repository: harmony-one/mcl - path: mcl - ref: master - fetch-depth: 1 - persist-credentials: false - - - name: Checkout bls - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 - with: - repository: harmony-one/bls - path: bls - ref: master - fetch-depth: 1 - persist-credentials: false - - name: Checkout harmony core code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 with: diff --git a/Dockerfile b/Dockerfile index 90ce25f97b..35a6b890d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,6 @@ ENV GOPATH=/root/go ENV GO111MODULE=on ENV HMY_PATH=${GOPATH}/src/github.com/harmony-one ENV OPENSSL_DIR=/usr/lib/ssl -ENV MCL_DIR=${HMY_PATH}/mcl -ENV BLS_DIR=${HMY_PATH}/bls -ENV CGO_CFLAGS="-I${BLS_DIR}/include -I${MCL_DIR}/include" -ENV CGO_LDFLAGS="-L${BLS_DIR}/lib" -ENV LD_LIBRARY_PATH=${BLS_DIR}/lib:${MCL_DIR}/lib ENV GIMME_GO_VERSION=${GOLANG_VERSION} ENV PATH="/root/bin:${PATH}" @@ -31,14 +26,8 @@ RUN eval "$(~/bin/gimme ${GIMME_GO_VERSION})" RUN git clone https://github.com/harmony-one/harmony.git ${HMY_PATH}/harmony -RUN git clone https://github.com/harmony-one/bls.git ${HMY_PATH}/bls - -RUN git clone https://github.com/harmony-one/mcl.git ${HMY_PATH}/mcl - RUN git clone https://github.com/harmony-one/go-sdk.git ${HMY_PATH}/go-sdk -RUN cd ${HMY_PATH}/bls && make -j8 BLS_SWAP_G=1 - RUN touch /root/.bash_profile && \ gimme ${GIMME_GO_VERSION} >> /root/.bash_profile && \ echo "GIMME_GO_VERSION='${GIMME_GO_VERSION}'" >> /root/.bash_profile && \ diff --git a/Makefile b/Makefile index 844f6c5099..36d0f99625 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,3 @@ -TOP:=$(realpath ..) -export CGO_CFLAGS:=-I$(TOP)/bls/include -I$(TOP)/mcl/include -I/opt/homebrew/opt/openssl@1.1/include -export CGO_LDFLAGS:=-L$(TOP)/bls/lib -L/opt/homebrew/opt/openssl@1.1/lib -export LD_LIBRARY_PATH:=$(TOP)/bls/lib:$(TOP)/mcl/lib:/opt/homebrew/opt/openssl@1.1/lib:/opt/homebrew/opt/gmp/lib/:/opt/homebrew/opt/openssl@1.1/lib -export LIBRARY_PATH:=$(LD_LIBRARY_PATH) -export DYLD_FALLBACK_LIBRARY_PATH:=$(LD_LIBRARY_PATH) export GO111MODULE:=on PKGNAME=harmony VERSION?=$(shell git tag -l --sort=-v:refname | head -n 1 | tr -d v) @@ -70,8 +64,7 @@ help: @echo "protofiles - generate Go code from protobuf files" libs: - make -C $(TOP)/mcl -j8 - make -C $(TOP)/bls BLS_SWAP_G=1 -j8 + go mod download github.com/herumi/bls-eth-go-binary exe: bash ./scripts/go_executable_build.sh -S @@ -144,11 +137,8 @@ clean: rm -f coverage.txt distclean: clean - make -C $(TOP)/mcl clean - make -C $(TOP)/bls clean go-get: - source ./scripts/setup_bls_build_flags.sh go get -v ./... test: @@ -170,8 +160,6 @@ test-rosetta-attach: bash ./test/rosetta.sh attach linux_static: - make -C $(TOP)/mcl -j8 - make -C $(TOP)/bls minimised_static BLS_SWAP_G=1 -j8 bash ./scripts/go_executable_build.sh -s linux_static_quick: @@ -271,4 +259,4 @@ debug-delete-log: docker-go-test: - docker run --rm -it -v "$PWD":/go/src/github.com/harmony-one/harmony frozen621/harmony-test bash -c 'make go-test' \ No newline at end of file + docker run --rm -it -v "$PWD":/go/src/github.com/harmony-one/harmony frozen621/harmony-test bash -c 'make go-test' diff --git a/README.md b/README.md index ba69a146f5..df73df32ba 100644 --- a/README.md +++ b/README.md @@ -54,38 +54,15 @@ On macOS, make sure you have the **Xcode Command Line Tools** installed. This in xcode-select --install ``` -## Setting Up MCL & BLS Libraries on macOS +## BLS dependency -The Harmony project depends on the MCL (multi-curve library) and BLS (Boneh-Lynn-Shacham) cryptographic libraries. These need to be installed and configured before building the project. +Harmony uses Herumi's official Go package with prebuilt static BLS libraries. +No separate BLS or MCL checkout is required: -### Clone and Build MCL & BLS Repositories -First, clone the MCL and BLS repositories: ```bash -git clone https://github.com/harmony-one/mcl.git -git clone https://github.com/harmony-one/bls.git -``` - -### Update `.zshrc` for MCL and BLS Paths - -To ensure the libraries are correctly located when building the project, you need to add the MCL and BLS library paths to your `.zshrc` file. - -Add the following lines to your `.zshrc` (or `.bash_profile` for bash users): -```bash -# MCL & BLS paths for Harmony -export MCL_PATH=$GOPATH/src/github.com/harmony-one/mcl -export BLS_PATH=$GOPATH/src/github.com/harmony-one/bls - -# Add library paths for MCL and BLS -export CGO_CFLAGS="-I$MCL_PATH/include -I$BLS_PATH/include -I/opt/homebrew/opt/openssl@1.1/include" -export CGO_LDFLAGS="-L$MCL_PATH/lib -L$BLS_PATH/lib -L/opt/homebrew/opt/openssl@1.1/lib" -export LD_LIBRARY_PATH=$MCL_PATH/lib:$BLS_PATH/lib:/opt/homebrew/opt/openssl@1.1/lib -export LIBRARY_PATH=$LD_LIBRARY_PATH -export DYLD_FALLBACK_LIBRARY_PATH=$LD_LIBRARY_PATH -``` - -Then, apply the changes by running: -```bash -source ~/.zshrc +git clone https://github.com/harmony-one/harmony.git +cd harmony +go mod download ``` ## Dev Environment @@ -102,10 +79,8 @@ cd $(go env GOPATH)/src/github.com/harmony-one ``` > If you get 'unknown command' or something along those lines, make sure to install [golang](https://golang.org/doc/install) first. -2. Clone this repo & dependent repos. +2. Clone this repo. ```bash -git clone https://github.com/harmony-one/mcl.git -git clone https://github.com/harmony-one/bls.git git clone https://github.com/harmony-one/harmony.git cd harmony ``` @@ -151,17 +126,7 @@ Learn more about docker [here](https://docker-curriculum.com/). The `make` command should automatically build the Harmony binary & all dependent libs. -However, if you wish to bypass the Makefile, first export the build flags: -```bash -export CGO_CFLAGS="-I$GOPATH/src/github.com/harmony-one/bls/include -I$GOPATH/src/github.com/harmony-one/mcl/include -I/opt/homebrew/opt/openssl@1.1/include" -export CGO_LDFLAGS="-L$GOPATH/src/github.com/harmony-one/bls/lib -L/opt/homebrew/opt/openssl@1.1/lib" -export LD_LIBRARY_PATH=$GOPATH/src/github.com/harmony-one/bls/lib:$GOPATH/src/github.com/harmony-one/mcl/lib:/opt/homebrew/opt/openssl@1.1/lib -export LIBRARY_PATH=$LD_LIBRARY_PATH -export DYLD_FALLBACK_LIBRARY_PATH=$LD_LIBRARY_PATH -export GO111MODULE=on -``` - -Then you can build all executables with the following command: +You can build all executables directly with: ```bash bash ./scripts/go_executable_build.sh -S ``` @@ -276,4 +241,3 @@ See [`CONTRIBUTING`](CONTRIBUTING.md) for details. - Integration with WASM - Fast state synchronization - Auditable privacy asset using ZK proof - diff --git a/Test.Dockerfile b/Test.Dockerfile index ad0a974f3b..4a0863c510 100644 --- a/Test.Dockerfile +++ b/Test.Dockerfile @@ -8,15 +8,11 @@ RUN apt-get update && \ WORKDIR /go/src/github.com/harmony-one -RUN git clone https://github.com/harmony-one/mcl.git && \ - git clone https://github.com/harmony-one/bls.git - RUN echo "Cloning branch: ${ENV}" && \ git clone -b ${ENV} https://github.com/harmony-one/harmony.git && \ cd harmony && \ - go mod tidy && \ - make deps + go mod download WORKDIR /go/src/github.com/harmony-one/harmony -CMD ["make", "go-test"] \ No newline at end of file +CMD ["make", "go-test"] diff --git a/cmd/harmony/main.go b/cmd/harmony/main.go index 791bf21816..ff65c133e5 100644 --- a/cmd/harmony/main.go +++ b/cmd/harmony/main.go @@ -16,7 +16,6 @@ import ( ethCommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/log" - "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/api/service" "github.com/harmony-one/harmony/api/service/crosslink_sending" "github.com/harmony-one/harmony/api/service/pprof" @@ -28,6 +27,7 @@ import ( "github.com/harmony-one/harmony/consensus" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core" + bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/chain" "github.com/harmony-one/harmony/internal/cli" "github.com/harmony-one/harmony/internal/common" diff --git a/consensus/beacon_header_slash_test.go b/consensus/beacon_header_slash_test.go index 5399db5b5d..33e2063d6f 100644 --- a/consensus/beacon_header_slash_test.go +++ b/consensus/beacon_header_slash_test.go @@ -6,7 +6,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" @@ -18,6 +17,7 @@ import ( coretypes "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" chain2 "github.com/harmony-one/harmony/internal/chain" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/consensus/checks.go b/consensus/checks.go index 739c19dd6c..d927653bf4 100644 --- a/consensus/checks.go +++ b/consensus/checks.go @@ -4,10 +4,10 @@ import ( "bytes" "encoding/binary" - libbls "github.com/harmony-one/bls/ffi/go/bls" msg_pb "github.com/harmony-one/harmony/api/proto/message" "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/crypto/bls" + libbls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" "github.com/pkg/errors" protobuf "google.golang.org/protobuf/proto" diff --git a/consensus/consensus.go b/consensus/consensus.go index 311c01967e..251f5acdc1 100644 --- a/consensus/consensus.go +++ b/consensus/consensus.go @@ -7,13 +7,13 @@ import ( "time" "github.com/harmony-one/abool" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/consensus/engine" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core" "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/crypto/bls" bls_cosi "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/registry" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/multibls" diff --git a/consensus/consensus_service.go b/consensus/consensus_service.go index 0cc54f8829..cb1ed1b16e 100644 --- a/consensus/consensus_service.go +++ b/consensus/consensus_service.go @@ -6,7 +6,6 @@ import ( "time" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/api/proto" msg_pb "github.com/harmony-one/harmony/api/proto/message" consensus_engine "github.com/harmony-one/harmony/consensus/engine" @@ -16,6 +15,7 @@ import ( "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/crypto/bls" bls_cosi "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" "github.com/harmony-one/harmony/internal/chain" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" diff --git a/consensus/consensus_v2.go b/consensus/consensus_v2.go index 728a964687..7305bede5b 100644 --- a/consensus/consensus_v2.go +++ b/consensus/consensus_v2.go @@ -9,7 +9,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" - bls2 "github.com/harmony-one/bls/ffi/go/bls" msg_pb "github.com/harmony-one/harmony/api/proto/message" proto_node "github.com/harmony-one/harmony/api/proto/node" "github.com/harmony-one/harmony/block" @@ -18,6 +17,7 @@ import ( "github.com/harmony-one/harmony/core" "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/crypto/bls" + bls2 "github.com/harmony-one/harmony/crypto/bls/core" vrf_bls "github.com/harmony-one/harmony/crypto/vrf/bls" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" "github.com/harmony-one/harmony/numeric" diff --git a/consensus/construct.go b/consensus/construct.go index dab4026918..f2a39f20a9 100644 --- a/consensus/construct.go +++ b/consensus/construct.go @@ -4,11 +4,11 @@ import ( "bytes" "errors" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/api/proto" msg_pb "github.com/harmony-one/harmony/api/proto/message" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" protobuf "google.golang.org/protobuf/proto" ) diff --git a/consensus/construct_test.go b/consensus/construct_test.go index 96bb21390c..f012479d09 100644 --- a/consensus/construct_test.go +++ b/consensus/construct_test.go @@ -5,10 +5,10 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" msg_pb "github.com/harmony-one/harmony/api/proto/message" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/registry" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/multibls" diff --git a/consensus/crosslink_cache_invalid_shard_reward_test.go b/consensus/crosslink_cache_invalid_shard_reward_test.go index ea38137f04..768c7a48aa 100644 --- a/consensus/crosslink_cache_invalid_shard_reward_test.go +++ b/consensus/crosslink_cache_invalid_shard_reward_test.go @@ -8,7 +8,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/consensus/quorum" @@ -19,6 +18,7 @@ import ( coretypes "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" chain2 "github.com/harmony-one/harmony/internal/chain" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/consensus/double_sign.go b/consensus/double_sign.go index 6709d540a0..30cb0bd596 100644 --- a/consensus/double_sign.go +++ b/consensus/double_sign.go @@ -5,9 +5,9 @@ import ( "sort" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/staking/slash" ) diff --git a/consensus/fbft_log.go b/consensus/fbft_log.go index 60c4d248e1..da8c407a40 100644 --- a/consensus/fbft_log.go +++ b/consensus/fbft_log.go @@ -8,7 +8,7 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/pkg/errors" "github.com/rs/zerolog" diff --git a/consensus/leader.go b/consensus/leader.go index af7e39a0d7..9e901e70c6 100644 --- a/consensus/leader.go +++ b/consensus/leader.go @@ -9,10 +9,10 @@ import ( nodeconfig "github.com/harmony-one/harmony/internal/configs/node" "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" msg_pb "github.com/harmony-one/harmony/api/proto/message" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core/types" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/p2p" ) diff --git a/consensus/quorum/one-node-one-vote.go b/consensus/quorum/one-node-one-vote.go index 4962dfc86e..2662b0360d 100644 --- a/consensus/quorum/one-node-one-vote.go +++ b/consensus/quorum/one-node-one-vote.go @@ -6,8 +6,8 @@ import ( "github.com/pkg/errors" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/ethereum/go-ethereum/common" "github.com/harmony-one/harmony/consensus/votepower" diff --git a/consensus/quorum/one-node-staked-vote.go b/consensus/quorum/one-node-staked-vote.go index 45f4289eab..b681bdbae1 100644 --- a/consensus/quorum/one-node-staked-vote.go +++ b/consensus/quorum/one-node-staked-vote.go @@ -9,7 +9,7 @@ import ( "github.com/harmony-one/harmony/internal/utils" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/pkg/errors" "github.com/harmony-one/harmony/consensus/votepower" diff --git a/consensus/quorum/one-node-staked-vote_test.go b/consensus/quorum/one-node-staked-vote_test.go index fcce3c61b0..c3e1451dfe 100644 --- a/consensus/quorum/one-node-staked-vote_test.go +++ b/consensus/quorum/one-node-staked-vote_test.go @@ -12,7 +12,7 @@ import ( shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/numeric" "github.com/harmony-one/harmony/shard" ) @@ -42,7 +42,9 @@ func generateRandomSlot() (shard.Slot, bls_core.SecretKey) { addr := common.Address{} addr.SetBytes(big.NewInt(int64(accountGen.Int63n(maxAccountGen))).Bytes()) secretKey := bls_core.SecretKey{} - secretKey.Deserialize(big.NewInt(int64(keyGen.Int63n(maxKeyGen))).Bytes()) + if err := secretKey.SetLittleEndian(big.NewInt(int64(keyGen.Int63n(maxKeyGen))).Bytes()); err != nil { + panic(err) + } key := bls.SerializedPublicKey{} key.FromLibBLSPublicKey(secretKey.GetPublicKey()) stake := numeric.NewDecFromBigInt(big.NewInt(int64(stakeGen.Int63n(maxStakeGen)))) diff --git a/consensus/quorum/quorom_test.go b/consensus/quorum/quorom_test.go index 6b0ae95968..59ea163b90 100644 --- a/consensus/quorum/quorom_test.go +++ b/consensus/quorum/quorom_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - bls_core "github.com/harmony-one/bls/ffi/go/bls" harmony_bls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/harmony-one/harmony/shard" "github.com/stretchr/testify/assert" diff --git a/consensus/quorum/quorum.go b/consensus/quorum/quorum.go index c6954f0246..3f6fc99e96 100644 --- a/consensus/quorum/quorum.go +++ b/consensus/quorum/quorum.go @@ -8,9 +8,9 @@ import ( "github.com/harmony-one/harmony/crypto/bls" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/consensus/votepower" bls_cosi "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/multibls" diff --git a/consensus/quorum/thread_safe_decider.go b/consensus/quorum/thread_safe_decider.go index 5a74975f91..bfa6e7e3e2 100644 --- a/consensus/quorum/thread_safe_decider.go +++ b/consensus/quorum/thread_safe_decider.go @@ -5,9 +5,9 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/consensus/votepower" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/harmony-one/harmony/multibls" "github.com/harmony-one/harmony/numeric" diff --git a/consensus/slash_group_test.go b/consensus/slash_group_test.go index 409b428566..e8dde17f9b 100644 --- a/consensus/slash_group_test.go +++ b/consensus/slash_group_test.go @@ -6,7 +6,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" @@ -16,6 +15,7 @@ import ( coretypes "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" chain2 "github.com/harmony-one/harmony/internal/chain" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/consensus/validator_wrapper_payout_test.go b/consensus/validator_wrapper_payout_test.go index 5301d947ed..c529bbb49e 100644 --- a/consensus/validator_wrapper_payout_test.go +++ b/consensus/validator_wrapper_payout_test.go @@ -10,7 +10,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" @@ -22,6 +21,7 @@ import ( coretypes "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" hmybls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" chain2 "github.com/harmony-one/harmony/internal/chain" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/consensus/view_change_construct.go b/consensus/view_change_construct.go index 5d25531757..80b3f5ef9d 100644 --- a/consensus/view_change_construct.go +++ b/consensus/view_change_construct.go @@ -10,10 +10,10 @@ import ( "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core/types" - bls_core "github.com/harmony-one/bls/ffi/go/bls" msg_pb "github.com/harmony-one/harmony/api/proto/message" "github.com/harmony-one/harmony/crypto/bls" bls_cosi "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/chain" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/multibls" diff --git a/consensus/view_change_msg.go b/consensus/view_change_msg.go index 1ba9c76063..fb939c20df 100644 --- a/consensus/view_change_msg.go +++ b/consensus/view_change_msg.go @@ -7,10 +7,10 @@ import ( "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/api/proto" msg_pb "github.com/harmony-one/harmony/api/proto/message" bls_cosi "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/multibls" "github.com/pkg/errors" diff --git a/consensus/view_change_test.go b/consensus/view_change_test.go index 9514981d55..eae7f35998 100644 --- a/consensus/view_change_test.go +++ b/consensus/view_change_test.go @@ -5,8 +5,8 @@ import ( "github.com/harmony-one/harmony/crypto/bls" - bls_core "github.com/harmony-one/bls/ffi/go/bls" harmony_bls "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/stretchr/testify/assert" ) diff --git a/consensus/votepower/roster.go b/consensus/votepower/roster.go index 96a3ac9c6b..b82ef97fe7 100644 --- a/consensus/votepower/roster.go +++ b/consensus/votepower/roster.go @@ -10,8 +10,8 @@ import ( "github.com/harmony-one/harmony/shard" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" common2 "github.com/harmony-one/harmony/internal/common" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/numeric" diff --git a/consensus/votepower/roster_test.go b/consensus/votepower/roster_test.go index 0e7148fa27..f70cda0893 100644 --- a/consensus/votepower/roster_test.go +++ b/consensus/votepower/roster_test.go @@ -10,7 +10,7 @@ import ( shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/numeric" "github.com/harmony-one/harmony/shard" ) @@ -48,7 +48,9 @@ func generateRandomSlot() shard.Slot { addr := common.Address{} addr.SetBytes(big.NewInt(int64(accountGen.Int63n(maxAccountGen))).Bytes()) secretKey := bls_core.SecretKey{} - secretKey.Deserialize(big.NewInt(int64(keyGen.Int63n(maxKeyGen))).Bytes()) + if err := secretKey.SetLittleEndian(big.NewInt(int64(keyGen.Int63n(maxKeyGen))).Bytes()); err != nil { + panic(err) + } key := bls.SerializedPublicKey{} key.FromLibBLSPublicKey(secretKey.GetPublicKey()) stake := numeric.NewDecFromBigInt(big.NewInt(int64(stakeGen.Int63n(maxStakeGen)))) diff --git a/core/blockchain_impl.go b/core/blockchain_impl.go index a696f554f9..f0e7e237ff 100644 --- a/core/blockchain_impl.go +++ b/core/blockchain_impl.go @@ -40,7 +40,6 @@ import ( "github.com/ethereum/go-ethereum/metrics" "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" - bls2 "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" consensus_engine "github.com/harmony-one/harmony/consensus/engine" "github.com/harmony-one/harmony/consensus/reward" @@ -52,6 +51,7 @@ import ( "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" "github.com/harmony-one/harmony/crypto/bls" + bls2 "github.com/harmony-one/harmony/crypto/bls/core" harmonyconfig "github.com/harmony-one/harmony/internal/configs/harmony" "github.com/harmony-one/harmony/internal/params" "github.com/harmony-one/harmony/internal/tikv" diff --git a/core/evm_test.go b/core/evm_test.go index e095e5917b..ae63347f5f 100644 --- a/core/evm_test.go +++ b/core/evm_test.go @@ -11,7 +11,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" @@ -20,6 +19,7 @@ import ( "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" chain2 "github.com/harmony-one/harmony/internal/chain" "github.com/harmony-one/harmony/internal/params" diff --git a/core/rawdb/accessors_indexes_test.go b/core/rawdb/accessors_indexes_test.go index 41f54ac98a..0ede88d8d2 100644 --- a/core/rawdb/accessors_indexes_test.go +++ b/core/rawdb/accessors_indexes_test.go @@ -25,9 +25,9 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - bls_core "github.com/harmony-one/bls/ffi/go/bls" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/core/types" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" "github.com/harmony-one/harmony/numeric" staking "github.com/harmony-one/harmony/staking/types" diff --git a/core/tx_pool_test.go b/core/tx_pool_test.go index f1e7ce5ce7..826c1d283d 100644 --- a/core/tx_pool_test.go +++ b/core/tx_pool_test.go @@ -33,12 +33,12 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/event" - bls_core "github.com/harmony-one/bls/ffi/go/bls" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" "github.com/harmony-one/harmony/core/state" "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" chain2 "github.com/harmony-one/harmony/internal/chain" "github.com/harmony-one/harmony/internal/params" diff --git a/crypto/bls/bls.go b/crypto/bls/bls.go index 4b6fe593bc..5f47eb4510 100644 --- a/crypto/bls/bls.go +++ b/crypto/bls/bls.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "math/big" - "github.com/harmony-one/bls/ffi/go/bls" + bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/pkg/errors" ) diff --git a/crypto/bls/core/core.go b/crypto/bls/core/core.go new file mode 100644 index 0000000000..330770e8a1 --- /dev/null +++ b/crypto/bls/core/core.go @@ -0,0 +1,190 @@ +// Package bls configures the Herumi BLS implementation for Harmony consensus. +package bls + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "unsafe" + + herumi "github.com/herumi/bls-eth-go-binary/bls" +) + +type ( + ID = herumi.ID + + // SecretKey wraps Herumi's key with Harmony-safe cgo boundaries. + SecretKey struct{ herumi.SecretKey } + + // PublicKey wraps Herumi's public key with Harmony-specific operations. + PublicKey struct{ herumi.PublicKey } + + // Sign wraps a Herumi BLS signature. + Sign struct{ herumi.Sign } +) + +const BLS12_381 = herumi.BLS12_381 + +// This is the G1 generator produced by the historical Harmony BLS_SWAP_G +// implementation. Keeping it preserves all existing public keys. +const harmonyGenerator = "e500361ff315734cccd8f9b721ec159995e9e622be17afd41ac2f037a583e81b98c2320e0bf853a8f929e89e3d8ff504" + +func init() { + if err := Init(BLS12_381); err != nil { + panic(err) + } +} + +// Init initializes Herumi with Harmony's consensus-critical group layout, +// serialization, hash-to-curve mode and public-key generator. +func Init(curve int) error { + if err := herumi.Init(curve); err != nil { + return err + } + herumi.SetETHserialization(false) + if err := herumi.SetMapToMode(0); err != nil { + return fmt.Errorf("bls: failed to select Harmony map-to-curve mode: %w", err) + } + if err := herumi.SetETHmode(herumi.EthModeOld); err != nil { + return err + } + raw, err := hex.DecodeString(harmonyGenerator) + if err != nil { + return err + } + var generator PublicKey + if err := generator.Deserialize(raw); err != nil { + return fmt.Errorf("bls: decode Harmony generator: %w", err) + } + if err := herumi.SetGeneratorOfPublicKey(&generator.PublicKey); err != nil { + return fmt.Errorf("bls: install Harmony generator: %w", err) + } + return nil +} + +// GetAddress derives the Harmony address of a BLS public key. +func (pub *PublicKey) GetAddress() [20]byte { + var address [20]byte + if pub == nil { + return address + } + hash := sha256.Sum256(pub.Serialize()) + copy(address[:], hash[:len(address)]) + return address +} + +// GetPublicKey returns the public key corresponding to this secret key. +func (secret *SecretKey) GetPublicKey() *PublicKey { + if secret == nil { + return nil + } + public := secret.SecretKey.GetPublicKey() + if public == nil { + return nil + } + return &PublicKey{PublicKey: *public} +} + +// IsEqual reports whether two secret keys are equal. +func (secret *SecretKey) IsEqual(rhs *SecretKey) bool { + return secret != nil && rhs != nil && secret.SecretKey.IsEqual(&rhs.SecretKey) +} + +// Sign signs a string using Harmony's configured BLS mode. +func (secret *SecretKey) Sign(message string) *Sign { + if secret == nil { + return nil + } + signature := secret.SecretKey.Sign(message) + if signature == nil { + return nil + } + return &Sign{Sign: *signature} +} + +// SignHash copies the hash before crossing the cgo boundary. +func (secret *SecretKey) SignHash(hash []byte) *Sign { + if secret == nil { + return nil + } + signature := secret.SecretKey.SignHash(append([]byte(nil), hash...)) + if signature == nil { + return nil + } + return &Sign{Sign: *signature} +} + +// SignByte copies the message before crossing the cgo boundary. +func (secret *SecretKey) SignByte(message []byte) *Sign { + if secret == nil { + return nil + } + signature := secret.SecretKey.SignByte(append([]byte(nil), message...)) + if signature == nil { + return nil + } + return &Sign{Sign: *signature} +} + +// VerifyHash copies the hash before crossing the cgo boundary. +func (signature *Sign) VerifyHash(public *PublicKey, hash []byte) bool { + if signature == nil || public == nil { + return false + } + return signature.Sign.VerifyHash( + &public.PublicKey, + append([]byte(nil), hash...), + ) +} + +// VerifyByte copies the message before crossing the cgo boundary. +func (signature *Sign) VerifyByte(public *PublicKey, message []byte) bool { + if signature == nil || public == nil { + return false + } + return signature.Sign.VerifyByte( + &public.PublicKey, + append([]byte(nil), message...), + ) +} + +// Verify verifies a string signature. +func (signature *Sign) Verify(public *PublicKey, message string) bool { + if signature == nil || public == nil { + return false + } + return signature.Sign.Verify(&public.PublicKey, message) +} + +// Add adds another public key. +func (pub *PublicKey) Add(rhs *PublicKey) { + if pub != nil && rhs != nil { + pub.PublicKey.Add(&rhs.PublicKey) + } +} + +// IsEqual reports whether two public keys are equal. +func (pub *PublicKey) IsEqual(rhs *PublicKey) bool { + return pub != nil && rhs != nil && pub.PublicKey.IsEqual(&rhs.PublicKey) +} + +// Sub subtracts another BLS public key. +func (pub *PublicKey) Sub(rhs *PublicKey) { + if pub == nil || rhs == nil { + return + } + out := (*herumi.G1)(unsafe.Pointer(&pub.PublicKey)) + herumi.G1Sub(out, out, (*herumi.G1)(unsafe.Pointer(&rhs.PublicKey))) +} + +// Add adds another signature. +func (signature *Sign) Add(rhs *Sign) { + if signature != nil && rhs != nil { + signature.Sign.Add(&rhs.Sign) + } +} + +// IsEqual reports whether two signatures are equal. +func (signature *Sign) IsEqual(rhs *Sign) bool { + return signature != nil && rhs != nil && signature.Sign.IsEqual(&rhs.Sign) +} diff --git a/crypto/bls/core/core_test.go b/crypto/bls/core/core_test.go new file mode 100644 index 0000000000..5c18e6f593 --- /dev/null +++ b/crypto/bls/core/core_test.go @@ -0,0 +1,37 @@ +package bls + +import ( + "encoding/hex" + "testing" +) + +func TestHarmonyWireCompatibility(t *testing.T) { + var secret SecretKey + if err := secret.SetLittleEndian([]byte{1}); err != nil { + t.Fatal(err) + } + + const ( + wantSecret = "0100000000000000000000000000000000000000000000000000000000000000" + wantPublic = "e500361ff315734cccd8f9b721ec159995e9e622be17afd41ac2f037a583e81b98c2320e0bf853a8f929e89e3d8ff504" + wantSign = "c2aba747613499b2e086c3bd9714f6da7159b3cb256d246f1c41dcdd61b00fd608e7caadef2376ed77e68ffc0b486113617d21668e56f930bdee39af0af1fcb42dc7396c5de18af5f2560dc6a11f5e9dd04f9df5e1ac68773d85c8585dcf9c11" + wantHash = "1d730dd8da233d2fccc254b9b3fece52a6f15dd4522f3cd600f14551b5cd76c95ea6ebc7ec077ccc83dead3b2b5b8a12f05b8492119c773c81eb5fb56060f585e711e763f4a85221a5ba72894b87fab619fab4a4c0e478969125c75a9216d093" + ) + + assertHex := func(name string, got []byte, want string) { + t.Helper() + if hex.EncodeToString(got) != want { + t.Fatalf("%s changed:\n got %x\nwant %s", name, got, want) + } + } + + public := secret.GetPublicKey() + hash, err := hex.DecodeString("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + if err != nil { + t.Fatal(err) + } + assertHex("secret key", secret.Serialize(), wantSecret) + assertHex("public key", public.Serialize(), wantPublic) + assertHex("message signature", secret.Sign("harmony-bls-upgrade-compatibility").Serialize(), wantSign) + assertHex("hash signature", secret.SignHash(hash).Serialize(), wantHash) +} diff --git a/crypto/bls/mask.go b/crypto/bls/mask.go index 2bd908fd7c..cda255926a 100644 --- a/crypto/bls/mask.go +++ b/crypto/bls/mask.go @@ -1,7 +1,7 @@ package bls import ( - "github.com/harmony-one/bls/ffi/go/bls" + bls "github.com/harmony-one/harmony/crypto/bls/core" lru "github.com/hashicorp/golang-lru" "github.com/pkg/errors" ) @@ -44,7 +44,10 @@ func BytesToBLSPublicKey(bytes []byte) (*bls.PublicKey, error) { return nil, errPubKeyCast } pubKey := &bls.PublicKey{} - err := pubKey.Deserialize(bytes) + // The upstream wrapper passes this buffer to C. Copy it so callers may + // safely provide a slice backed by a Go struct containing pointer fields. + buf := append([]byte(nil), bytes...) + err := pubKey.Deserialize(buf) if err == nil { BLSPubKeyCache.Add(kkey, *pubKey) diff --git a/crypto/bls/mask_test.go b/crypto/bls/mask_test.go index 17770fd668..d920980216 100644 --- a/crypto/bls/mask_test.go +++ b/crypto/bls/mask_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/harmony-one/bls/ffi/go/bls" + bls "github.com/harmony-one/harmony/crypto/bls/core" ) // Test the basic functionality of a BLS multi-sig mask. diff --git a/crypto/vrf/bls/bls_vrf.go b/crypto/vrf/bls/bls_vrf.go index 54e1820c76..e7d273c974 100644 --- a/crypto/vrf/bls/bls_vrf.go +++ b/crypto/vrf/bls/bls_vrf.go @@ -5,7 +5,7 @@ import ( "crypto/sha256" "errors" - "github.com/harmony-one/bls/ffi/go/bls" + bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/vrf" ) diff --git a/go.mod b/go.mod index c29c096e16..42658cd200 100644 --- a/go.mod +++ b/go.mod @@ -24,11 +24,11 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.3 github.com/harmony-one/abool v1.0.1 - github.com/harmony-one/bls v0.0.6 github.com/harmony-one/taggedrlp v0.1.4 github.com/harmony-one/vdf v0.0.0-20190924175951-620379da8849 github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d + github.com/herumi/bls-eth-go-binary v1.37.0 github.com/ipfs/go-ds-badger v0.3.0 github.com/json-iterator/go v1.1.12 github.com/libp2p/go-libp2p v0.36.2 diff --git a/go.sum b/go.sum index c50ab74642..cfb51d999a 100644 --- a/go.sum +++ b/go.sum @@ -645,8 +645,6 @@ github.com/grpc-ecosystem/grpc-gateway v1.12.1/go.mod h1:8XEsbTttt/W+VvjtQhLACqC github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/harmony-one/abool v1.0.1 h1:SjXLmrr3W8h6lY37gRuWtLiRknUOchnUnsXJWK6Gbm4= github.com/harmony-one/abool v1.0.1/go.mod h1:9sq0PJzb1SqRpKrpEV4Ttvm9WV5uud8sfrsPw3AIBJA= -github.com/harmony-one/bls v0.0.6 h1:KG4q4JwdkPf3DtFvJmAgMRWT6QdY1A/wqN/Qt+S4VaQ= -github.com/harmony-one/bls v0.0.6/go.mod h1:ML9osB/z3hR9WAYZVj7qH+IP6oaPRPmshDbxrQyia7g= github.com/harmony-one/taggedrlp v0.1.4 h1:RZ+qy0VCzT+d/mTfq23gH3an5tSvxOhg6AddLDO6tKw= github.com/harmony-one/taggedrlp v0.1.4/go.mod h1:osO5TRXLKdgCP+oj2J9qfqhywMOOA+4nP5q+o8nDSYA= github.com/harmony-one/vdf v0.0.0-20190924175951-620379da8849 h1:rMY4jLAen3pMTq9KO7kSXzuMaicnOHP5n1MgpA1T6G4= @@ -686,6 +684,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/herumi/bls-eth-go-binary v1.37.0 h1:EaLF+MWndrF3Vbd9VkbG0T9tad3wBbGwh+6kCYcY5QA= +github.com/herumi/bls-eth-go-binary v1.37.0/go.mod h1:luAnRm3OsMQeokhGzpYmc0ZKwawY7o87PUEP11Z7r7U= github.com/holiman/big v0.0.0-20221017200358-a027dc42d04e h1:pIYdhNkDh+YENVNi3gto8n9hAmRxKxoar0iE6BLucjw= github.com/holiman/big v0.0.0-20221017200358-a027dc42d04e/go.mod h1:j9cQbcqHQujT0oKJ38PylVfqohClLr3CvDC+Qcg+lhU= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= diff --git a/internal/blsgen/helper.go b/internal/blsgen/helper.go index a1b178a78b..a9856743ed 100644 --- a/internal/blsgen/helper.go +++ b/internal/blsgen/helper.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/multibls" ) diff --git a/internal/blsgen/helper_test.go b/internal/blsgen/helper_test.go index bd5580f00e..03c0bc462a 100644 --- a/internal/blsgen/helper_test.go +++ b/internal/blsgen/helper_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" ) const ( diff --git a/internal/blsgen/kms.go b/internal/blsgen/kms.go index 0a197a54e0..4bc5bf8e5b 100644 --- a/internal/blsgen/kms.go +++ b/internal/blsgen/kms.go @@ -11,7 +11,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/kms" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/pkg/errors" ) diff --git a/internal/blsgen/kms_test.go b/internal/blsgen/kms_test.go index 7fde9fdfcb..1e0f737902 100644 --- a/internal/blsgen/kms_test.go +++ b/internal/blsgen/kms_test.go @@ -13,8 +13,8 @@ import ( "github.com/aws/aws-sdk-go/service/kms" "github.com/ethereum/go-ethereum/common" - ffi_bls "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + ffi_bls "github.com/harmony-one/harmony/crypto/bls/core" ) var TestAwsConfig = AwsConfig{ diff --git a/internal/blsgen/lib.go b/internal/blsgen/lib.go index ec1edc0b4f..c46a3b21df 100644 --- a/internal/blsgen/lib.go +++ b/internal/blsgen/lib.go @@ -12,8 +12,8 @@ import ( "strings" "github.com/aws/aws-sdk-go/service/kms" - ffi_bls "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + ffi_bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/pkg/errors" ) diff --git a/internal/blsgen/loader.go b/internal/blsgen/loader.go index 4a3b1dc6aa..87982ca0bc 100644 --- a/internal/blsgen/loader.go +++ b/internal/blsgen/loader.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/multibls" ) diff --git a/internal/blsgen/passphrase.go b/internal/blsgen/passphrase.go index e26a0a1ebd..f01a368dbe 100644 --- a/internal/blsgen/passphrase.go +++ b/internal/blsgen/passphrase.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" ) // PassSrcType is the type of passphrase provider source. diff --git a/internal/blsgen/utils.go b/internal/blsgen/utils.go index 0ab96f4c96..a0118bbfbe 100644 --- a/internal/blsgen/utils.go +++ b/internal/blsgen/utils.go @@ -5,7 +5,7 @@ import ( "os" "strings" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" ) func loadBasicKeyWithProvider(blsKeyFile string, pp passProvider) (*bls_core.SecretKey, error) { diff --git a/internal/chain/engine.go b/internal/chain/engine.go index 8b701a3dce..8500361751 100644 --- a/internal/chain/engine.go +++ b/internal/chain/engine.go @@ -10,7 +10,7 @@ import ( "github.com/harmony-one/harmony/internal/params" "github.com/harmony-one/harmony/numeric" - bls2 "github.com/harmony-one/bls/ffi/go/bls" + bls2 "github.com/harmony-one/harmony/crypto/bls/core" blsvrf "github.com/harmony-one/harmony/crypto/vrf/bls" "github.com/ethereum/go-ethereum/common" diff --git a/internal/chain/engine_test.go b/internal/chain/engine_test.go index 677cb26a53..170cce75fd 100644 --- a/internal/chain/engine_test.go +++ b/internal/chain/engine_test.go @@ -7,12 +7,12 @@ import ( "time" "github.com/ethereum/go-ethereum/trie" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/consensus/engine" consensus_sig "github.com/harmony-one/harmony/consensus/signature" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/numeric" "github.com/harmony-one/harmony/shard" "github.com/harmony-one/harmony/staking/effective" diff --git a/internal/chain/sig.go b/internal/chain/sig.go index 23b51d5f1b..912c95cbd2 100644 --- a/internal/chain/sig.go +++ b/internal/chain/sig.go @@ -3,7 +3,7 @@ package chain import ( "errors" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/bls" "github.com/harmony-one/harmony/internal/utils" diff --git a/internal/configs/node/config.go b/internal/configs/node/config.go index 6f7caf3cf7..c694dcc4f4 100644 --- a/internal/configs/node/config.go +++ b/internal/configs/node/config.go @@ -10,8 +10,8 @@ import ( "sync" "time" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/harmony-one/harmony/internal/params" "github.com/harmony-one/harmony/multibls" diff --git a/internal/configs/node/config_test.go b/internal/configs/node/config_test.go index 9a6606c4f9..8a116afa34 100644 --- a/internal/configs/node/config_test.go +++ b/internal/configs/node/config_test.go @@ -3,8 +3,8 @@ package nodeconfig import ( "testing" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/blsgen" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/internal/genesis/genesis_test.go b/internal/genesis/genesis_test.go index 4ae5f8a423..59cf5425df 100644 --- a/internal/genesis/genesis_test.go +++ b/internal/genesis/genesis_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcutil/bech32" ethCommon "github.com/ethereum/go-ethereum/common" - "github.com/harmony-one/bls/ffi/go/bls" + bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/common" ) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 5152eb28e0..fe9f241a12 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,10 +5,10 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/consensus/engine" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" "github.com/harmony-one/harmony/internal/shardchain" "github.com/harmony-one/harmony/multibls" diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 28af81aed8..b277e77d33 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -15,8 +15,8 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" p2p_crypto "github.com/libp2p/go-libp2p/core/crypto" "github.com/pkg/errors" ) diff --git a/multibls/multibls.go b/multibls/multibls.go index dc09d9a868..22bb84ba19 100644 --- a/multibls/multibls.go +++ b/multibls/multibls.go @@ -5,8 +5,8 @@ import ( "github.com/harmony-one/harmony/internal/utils" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" ) // PrivateKeys stores the bls secret keys that belongs to the node diff --git a/node/harmony/addresses.go b/node/harmony/addresses.go index 44279981c2..6ee8d6d657 100644 --- a/node/harmony/addresses.go +++ b/node/harmony/addresses.go @@ -5,8 +5,8 @@ import ( "sync" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" common2 "github.com/harmony-one/harmony/internal/common" "github.com/harmony-one/harmony/internal/registry" "github.com/harmony-one/harmony/internal/utils" diff --git a/node/harmony/node_cross_link.go b/node/harmony/node_cross_link.go index c772a9309d..271c842621 100644 --- a/node/harmony/node_cross_link.go +++ b/node/harmony/node_cross_link.go @@ -6,9 +6,9 @@ import ( common2 "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" - ffi_bls "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/core" "github.com/harmony-one/harmony/core/types" + ffi_bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/shard" "github.com/pkg/errors" diff --git a/node/harmony/node_test.go b/node/harmony/node_test.go index c7444d8d8f..48c5a504a8 100644 --- a/node/harmony/node_test.go +++ b/node/harmony/node_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - ffi_bls "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/consensus" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core" + ffi_bls "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/internal/chain" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" "github.com/harmony-one/harmony/internal/registry" diff --git a/node/harmony/worker/slash_duplicate_reporter_test.go b/node/harmony/worker/slash_duplicate_reporter_test.go index ad3f6b6447..d5229b3a84 100644 --- a/node/harmony/worker/slash_duplicate_reporter_test.go +++ b/node/harmony/worker/slash_duplicate_reporter_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" consensus_sig "github.com/harmony-one/harmony/consensus/signature" @@ -16,6 +15,7 @@ import ( "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/core/vm" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" chain2 "github.com/harmony-one/harmony/internal/chain" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/p2p/host.go b/p2p/host.go index c89fbc9189..23dbb33322 100644 --- a/p2p/host.go +++ b/p2p/host.go @@ -14,9 +14,9 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/harmony-one/abool" - "github.com/harmony-one/bls/ffi/go/bls" prom "github.com/harmony-one/harmony/api/service/prometheus" "github.com/harmony-one/harmony/common/clock" + bls "github.com/harmony-one/harmony/crypto/bls/core" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/internal/utils/blockedpeers" @@ -496,10 +496,14 @@ func NewHost(cfg HostConfig) (Host, error) { trustedPeersDnsResolvedCounter.Add(0) trustedPeersConnectFailuresCounter.Add(0) + consensusPubKey := "" + if self.ConsensusPubKey != nil { + consensusPubKey = self.ConsensusPubKey.SerializeToHexStr() + } utils.Logger().Info(). Str("self", net.JoinHostPort(self.IP, self.Port)). Interface("PeerID", self.PeerID). - Str("PubKey", self.ConsensusPubKey.SerializeToHexStr()). + Str("PubKey", consensusPubKey). Msg("libp2p host ready") return h, nil } diff --git a/rosetta/infra/Dockerfile b/rosetta/infra/Dockerfile index 28d11a885f..9b925b81fe 100644 --- a/rosetta/infra/Dockerfile +++ b/rosetta/infra/Dockerfile @@ -12,9 +12,7 @@ RUN mkdir -p $HMY_PATH WORKDIR $HMY_PATH -RUN git clone https://github.com/harmony-one/harmony.git && \ - git clone https://github.com/harmony-one/bls.git && \ - git clone https://github.com/harmony-one/mcl.git +RUN git clone https://github.com/harmony-one/harmony.git WORKDIR $HMY_PATH/harmony diff --git a/scripts/go_executable_build.sh b/scripts/go_executable_build.sh index 05c465cecf..5a43dc43da 100755 --- a/scripts/go_executable_build.sh +++ b/scripts/go_executable_build.sh @@ -34,22 +34,13 @@ case "${0}" in *) progdir=.;; esac -. "${progdir}/setup_bls_build_flags.sh" - declare -A LIB if [ "$(uname -s)" == "Darwin" ]; then MD5='md5 -r' GOOS=darwin - LIB[libbls384_256.dylib]=${BLS_DIR}/lib/libbls384_256.dylib - LIB[libmcl.dylib]=${MCL_DIR}/lib/libmcl.dylib - LIB[libgmp.10.dylib]=/opt/homebrew/opt/gmp/lib/libgmp.10.dylib - LIB[libgmpxx.4.dylib]=/opt/homebrew/opt/gmp/lib/libgmpxx.4.dylib - LIB[libcrypto.1.1.dylib]=/opt/homebrew/opt/openssl@1.1/lib/libcrypto.1.1.dylib else MD5=md5sum - LIB[libbls384_256.so]=${BLS_DIR}/lib/libbls384_256.so - LIB[libmcl.so]=${MCL_DIR}/lib/libmcl.so fi function usage @@ -155,11 +146,7 @@ function build_only fi done - $MD5 "${!SRC[@]}" "${!LIB[@]}" > md5sum.txt - # hardcode the prebuilt libcrypto to md5sum.txt - if [ "$(uname -s)" == "Linux" ]; then - echo '771150db04267126823190c873a96e48 libcrypto.so.10' >> md5sum.txt - fi + $MD5 "${!SRC[@]}" > md5sum.txt fi popd } diff --git a/scripts/macos_docker/Dockerfile b/scripts/macos_docker/Dockerfile index e8ad312bb6..bedc81bd99 100644 --- a/scripts/macos_docker/Dockerfile +++ b/scripts/macos_docker/Dockerfile @@ -9,11 +9,6 @@ ENV GOPATH=/root/go ENV GO111MODULE=on ENV HMY_PATH=${GOPATH}/src/github.com/harmony-one ENV OPENSSL_DIR=/usr/lib/ssl -ENV MCL_DIR=${HMY_PATH}/mcl -ENV BLS_DIR=${HMY_PATH}/bls -ENV CGO_CFLAGS="-I${BLS_DIR}/include -I${MCL_DIR}/include" -ENV CGO_LDFLAGS="-L${BLS_DIR}/lib" -ENV LD_LIBRARY_PATH=${BLS_DIR}/lib:${MCL_DIR}/lib ENV GIMME_GO_VERSION=${GOLANG_VERSION} ENV PATH="/root/bin:${PATH}" @@ -31,18 +26,8 @@ RUN eval "$(~/bin/gimme ${GIMME_GO_VERSION})" #RUN git clone https://github.com/harmony-one/harmony.git ${HMY_PATH}/harmony -RUN git clone https://github.com/harmony-one/mcl.git ${HMY_PATH}/mcl && \ - echo "mcl repository cloned" && \ - ls -la ${HMY_PATH}/mcl # List the contents of the mcl directory - -RUN git clone https://github.com/harmony-one/bls.git ${HMY_PATH}/bls && \ - echo "bls repository cloned" && \ - ls -la ${HMY_PATH}/bls # List the contents of the bls directory - RUN git clone https://github.com/harmony-one/go-sdk.git ${HMY_PATH}/go-sdk -RUN cd ${HMY_PATH}/bls && make -j8 BLS_SWAP_G=1 - RUN touch /root/.bash_profile && \ gimme ${GIMME_GO_VERSION} >> /root/.bash_profile && \ echo "GIMME_GO_VERSION='${GIMME_GO_VERSION}'" >> /root/.bash_profile && \ @@ -61,4 +46,3 @@ RUN . ~/.bash_profile; \ go install github.com/stamblerre/gocode; \ go install golang.org/x/tools/...; \ go install honnef.co/go/tools/cmd/staticcheck/... - diff --git a/scripts/setup_bls_build_flags.sh b/scripts/setup_bls_build_flags.sh deleted file mode 100644 index 92ee20579e..0000000000 --- a/scripts/setup_bls_build_flags.sh +++ /dev/null @@ -1,44 +0,0 @@ -# no shebang; to be sourced from other scripts - -unset -v progdir -case "${0}" in -*/*) progdir="${0%/*}";; -*) progdir=.;; -esac - -case "${HMY_PATH+set}" in -"") - unset -v gopath - gopath=$(go env GOPATH) - # HMY_PATH is the common root directory of all harmony repos - HMY_PATH="${gopath%%:*}/src/github.com/harmony-one" - if [ ! -d $HMY_PATH ]; then - # "env pwd" uses external pwd(1) implementation and not the Bash built-in, - # which does not fully dereference symlinks. - HMY_PATH=$(cd $progdir/../.. && env pwd) - fi - ;; -esac -: ${OPENSSL_DIR="/opt/homebrew/opt/openssl@1.1"} -: ${MCL_DIR="${HMY_PATH}/mcl"} -: ${BLS_DIR="${HMY_PATH}/bls"} -export CGO_CFLAGS="-I${BLS_DIR}/include -I${MCL_DIR}/include" -export CGO_LDFLAGS="-L${BLS_DIR}/lib" -export LD_LIBRARY_PATH=${BLS_DIR}/lib:${MCL_DIR}/lib - -OS=$(uname -s) -case $OS in - Darwin) - export CGO_CFLAGS="-I${BLS_DIR}/include -I${MCL_DIR}/include -I${OPENSSL_DIR}/include" - export CGO_LDFLAGS="-L${BLS_DIR}/lib -L${OPENSSL_DIR}/lib" - export LD_LIBRARY_PATH=${BLS_DIR}/lib:${MCL_DIR}/lib:${OPENSSL_DIR}/lib - export DYLD_FALLBACK_LIBRARY_PATH=$LD_LIBRARY_PATH - ;; -esac - -if [ "$1" = "-v" ]; then - echo "{ \"CGO_CFLAGS\" : \"$CGO_CFLAGS\", - \"CGO_LDFLAGS\" : \"$CGO_LDFLAGS\", - \"LD_LIBRARY_PATH\" : \"$LD_LIBRARY_PATH\", - \"DYLD_FALLBACK_LIBRARY_PATH\" : \"$DYLD_FALLBACK_LIBRARY_PATH\"}" | jq "." -fi diff --git a/scripts/travis_go_checker.sh b/scripts/travis_go_checker.sh index c6c7640caf..d115ce50e5 100755 --- a/scripts/travis_go_checker.sh +++ b/scripts/travis_go_checker.sh @@ -14,8 +14,6 @@ tmpdir= trap 'case "${tmpdir}" in ?*) rm -rf "${tmpdir}";; esac' EXIT tmpdir=$(mktemp -d) -. "${progdir}/setup_bls_build_flags.sh" - echo "Checking go.mod..." gomod_diff_output="${tmpdir}/gomod.diff" if git diff --exit-code -- go.mod > "${gomod_diff_output}" diff --git a/shard/committee/assignment.go b/shard/committee/assignment.go index 5fba3e6200..57f8153d62 100644 --- a/shard/committee/assignment.go +++ b/shard/committee/assignment.go @@ -9,9 +9,9 @@ import ( "github.com/harmony-one/harmony/crypto/bls" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/block" "github.com/harmony-one/harmony/core/types" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" common2 "github.com/harmony-one/harmony/internal/common" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" diff --git a/staking/effective/calculate_test.go b/staking/effective/calculate_test.go index 7127192c3b..ebbde3e7be 100644 --- a/staking/effective/calculate_test.go +++ b/staking/effective/calculate_test.go @@ -9,8 +9,8 @@ import ( "sort" "testing" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/ethereum/go-ethereum/common" "github.com/harmony-one/harmony/numeric" diff --git a/staking/slash/double-sign.go b/staking/slash/double-sign.go index dbbeb6a66a..03e6dbf78a 100644 --- a/staking/slash/double-sign.go +++ b/staking/slash/double-sign.go @@ -9,9 +9,9 @@ import ( "github.com/harmony-one/harmony/shard" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" consensus_sig "github.com/harmony-one/harmony/consensus/signature" "github.com/harmony-one/harmony/core/state" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" common2 "github.com/harmony-one/harmony/internal/common" "github.com/harmony-one/harmony/internal/utils" diff --git a/staking/slash/double-sign_test.go b/staking/slash/double-sign_test.go index fcb0972c75..506ea949d2 100644 --- a/staking/slash/double-sign_test.go +++ b/staking/slash/double-sign_test.go @@ -13,11 +13,11 @@ import ( "github.com/harmony-one/harmony/crypto/bls" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" blockfactory "github.com/harmony-one/harmony/block/factory" consensus_sig "github.com/harmony-one/harmony/consensus/signature" "github.com/harmony-one/harmony/core/state" "github.com/harmony-one/harmony/core/types" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" shardingconfig "github.com/harmony-one/harmony/internal/configs/sharding" "github.com/harmony-one/harmony/internal/params" "github.com/harmony-one/harmony/numeric" @@ -324,7 +324,7 @@ func TestVerify(t *testing.T) { sdb: defaultTestStateDB(), chain: defaultFakeBlockChain(), - expErr: errors.New("Empty buf"), + expErr: errors.New("err blsSignatureDeserialize"), }, { // false signature diff --git a/staking/types/transaction_test.go b/staking/types/transaction_test.go index 55f21053aa..2d251c54cf 100644 --- a/staking/types/transaction_test.go +++ b/staking/types/transaction_test.go @@ -9,7 +9,7 @@ import ( "github.com/harmony-one/harmony/crypto/bls" "github.com/ethereum/go-ethereum/common" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" common2 "github.com/harmony-one/harmony/internal/common" numeric "github.com/harmony-one/harmony/numeric" ) diff --git a/staking/types/validator.go b/staking/types/validator.go index 4f35a03e4f..395fdeec6b 100644 --- a/staking/types/validator.go +++ b/staking/types/validator.go @@ -9,9 +9,9 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" - bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/common/denominations" "github.com/harmony-one/harmony/consensus/votepower" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" common2 "github.com/harmony-one/harmony/internal/common" "github.com/harmony-one/harmony/internal/genesis" diff --git a/test/chain/reward/main.go b/test/chain/reward/main.go index aee4c4ed0e..4482cf27cc 100644 --- a/test/chain/reward/main.go +++ b/test/chain/reward/main.go @@ -8,7 +8,6 @@ import ( common2 "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - bls_core "github.com/harmony-one/bls/ffi/go/bls" msg_pb "github.com/harmony-one/harmony/api/proto/message" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/core" @@ -16,6 +15,7 @@ import ( "github.com/harmony-one/harmony/core/state" "github.com/harmony-one/harmony/core/vm" "github.com/harmony-one/harmony/crypto/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" "github.com/harmony-one/harmony/internal/chain" "github.com/harmony-one/harmony/internal/common" diff --git a/test/chain/vrf/main.go b/test/chain/vrf/main.go index 1a44b9e416..81f503c605 100644 --- a/test/chain/vrf/main.go +++ b/test/chain/vrf/main.go @@ -7,7 +7,7 @@ import ( "github.com/harmony-one/harmony/crypto/bls" - bls_core "github.com/harmony-one/bls/ffi/go/bls" + bls_core "github.com/harmony-one/harmony/crypto/bls/core" "github.com/harmony-one/harmony/crypto/hash" vrf_bls "github.com/harmony-one/harmony/crypto/vrf/bls" ) diff --git a/test/deploy.sh b/test/deploy.sh index b06e35cf01..69a86ca5bc 100755 --- a/test/deploy.sh +++ b/test/deploy.sh @@ -11,8 +11,6 @@ ROOT="${progdir}/.." USER=$(whoami) OS=$(uname -s) -. "${ROOT}/scripts/setup_bls_build_flags.sh" - function cleanup() { if [[ "${CLEAN_START}" == "true" ]]; then "${progdir}/kill_node.sh" diff --git a/test/deploy_newnode.sh b/test/deploy_newnode.sh index a678a5314a..29a1bdf83c 100755 --- a/test/deploy_newnode.sh +++ b/test/deploy_newnode.sh @@ -3,8 +3,6 @@ ROOT=$(dirname $0)/.. USER=$(whoami) -. "${ROOT}/scripts/setup_bls_build_flags.sh" - set -x set -eo pipefail diff --git a/test/helpers/p2p.go b/test/helpers/p2p.go index aaf087452c..6bb9af06a7 100644 --- a/test/helpers/p2p.go +++ b/test/helpers/p2p.go @@ -1,8 +1,8 @@ package helpers import ( - "github.com/harmony-one/bls/ffi/go/bls" harmony_bls "github.com/harmony-one/harmony/crypto/bls" + bls "github.com/harmony-one/harmony/crypto/bls/core" nodeconfig "github.com/harmony-one/harmony/internal/configs/node" "github.com/harmony-one/harmony/p2p" libp2p_crypto "github.com/libp2p/go-libp2p/core/crypto" From 2e5db93591ed29cba5be7c18d6f61d1234bbc23d Mon Sep 17 00:00:00 2001 From: Gheis Mohammadi Date: Tue, 28 Jul 2026 11:24:32 +0400 Subject: [PATCH 12/23] perf(state): write only dirty validator wrappers on Finalise (#5085) --- core/state/journal.go | 1 + core/state/statedb.go | 33 ++++- core/state/validator_dirty_test.go | 180 +++++++++++++++++++++++++++ core/state_processor.go | 1 + internal/chain/engine.go | 9 ++ staking/availability/interface.go | 1 + staking/availability/measure.go | 5 + staking/availability/measure_test.go | 4 + staking/slash/double-sign.go | 1 + 9 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 core/state/validator_dirty_test.go diff --git a/core/state/journal.go b/core/state/journal.go index c5e3f743e7..c301eac59a 100644 --- a/core/state/journal.go +++ b/core/state/journal.go @@ -158,6 +158,7 @@ func (v validatorWrapperChange) dirtied() *common.Address { // revert undoes the changes introduced by this journal entry. func (v validatorWrapperChange) revert(s *DB) { s.stateValidators[*(v.address)] = v.prev + s.MarkValidatorWrapperDirty(*(v.address)) } func (ch createObjectChange) revert(s *DB) { diff --git a/core/state/statedb.go b/core/state/statedb.go index fb5d1710de..9693e2b430 100644 --- a/core/state/statedb.go +++ b/core/state/statedb.go @@ -93,6 +93,7 @@ type DB struct { stateObjectsDirty map[common.Address]struct{} // State objects modified in the current execution stateObjectsDestruct map[common.Address]struct{} // State objects destructed in the block stateValidators map[common.Address]*stk.ValidatorWrapper + stateValidatorsDirty map[common.Address]struct{} // Cached wrappers that need a write on Finalise // DB error. // State objects are used by the consensus core and VM which are @@ -163,6 +164,7 @@ func New(root common.Hash, db Database, snaps *snapshot.Tree) (*DB, error) { stateObjectsDirty: make(map[common.Address]struct{}), stateObjectsDestruct: make(map[common.Address]struct{}), stateValidators: make(map[common.Address]*stk.ValidatorWrapper), + stateValidatorsDirty: make(map[common.Address]struct{}), logs: make(map[common.Hash][]*types2.Log), preimages: make(map[common.Hash][]byte), journal: newJournal(), @@ -224,6 +226,7 @@ func (db *DB) Reset(root common.Hash) error { db.stateObjectsPending = make(map[common.Address]struct{}) db.stateObjectsDirty = make(map[common.Address]struct{}) db.stateValidators = make(map[common.Address]*stk.ValidatorWrapper) + db.stateValidatorsDirty = make(map[common.Address]struct{}) db.thash = common.Hash{} db.bhash = common.Hash{} db.ethTxHash = common.Hash{} @@ -781,6 +784,7 @@ func (db *DB) Copy() *DB { stateObjectsDirty: make(map[common.Address]struct{}, len(db.journal.dirties)), stateObjectsDestruct: make(map[common.Address]struct{}, len(db.stateObjectsDestruct)), stateValidators: make(map[common.Address]*stk.ValidatorWrapper), + stateValidatorsDirty: make(map[common.Address]struct{}, len(db.stateValidatorsDirty)), refund: db.refund, logs: make(map[common.Hash][]*types2.Log, len(db.logs)), logSize: db.logSize, @@ -824,10 +828,17 @@ func (db *DB) Copy() *DB { for addr := range db.stateObjectsDestruct { state.stateObjectsDestruct[addr] = struct{}{} } + // Deep-copy all cached validator wrappers and preserve dirty flags. for addr, wrapper := range db.stateValidators { + if wrapper == nil { + continue + } copied := staketest.CopyValidatorWrapper(*wrapper) state.stateValidators[addr] = &copied } + for addr := range db.stateValidatorsDirty { + state.stateValidatorsDirty[addr] = struct{}{} + } for hash, logs := range db.logs { cpy := make([]*types2.Log, len(logs)) for i, l := range logs { @@ -912,16 +923,22 @@ func (db *DB) GetRefund() uint64 { // the journal as well as the refunds. Finalise, however, will not push any updates // into the tries just yet. Only IntermediateRoot or Commit will do that. func (db *DB) Finalise(deleteEmptyObjects bool) { - // Commit validator changes in cache to stateObjects - // TODO: remove validator cache after commit - for addr, wrapper := range db.stateValidators { + // Persist dirty validator wrappers into account code. + remainingDirty := make(map[common.Address]struct{}) + for addr := range db.stateValidatorsDirty { + wrapper, ok := db.stateValidators[addr] + if !ok || wrapper == nil { + continue + } if err := db.UpdateValidatorWrapper(addr, wrapper); err != nil { utils.Logger().Warn().Err(err). Str("name", wrapper.Name). Str("addr", addr.String()). Msg("Unable to update the validator wrapper on the finalize") + remainingDirty[addr] = struct{}{} } } + db.stateValidatorsDirty = remainingDirty addressesToPrefetch := make([][]byte, 0, len(db.journal.dirties)) for addr := range db.journal.dirties { obj, exist := db.stateObjects[addr] @@ -1297,6 +1314,14 @@ func (db *DB) CachedValidatorAddresses() []common.Address { return addrs } +// MarkValidatorWrapperDirty marks the cached validator wrapper for write on Finalise. +func (db *DB) MarkValidatorWrapperDirty(addr common.Address) { + if db.stateValidatorsDirty == nil { + db.stateValidatorsDirty = make(map[common.Address]struct{}) + } + db.stateValidatorsDirty[addr] = struct{}{} +} + // ValidatorWrapper retrieves the existing validator in the cache, if sendOriginal // else it will return a copy of the wrapper - which needs to be explicitly committed // with UpdateValidatorWrapper. @@ -1376,6 +1401,7 @@ func (db *DB) UpdateValidatorWrapper( db.SetCode(addr, by, true) // update cache db.stateValidators[addr] = val + db.MarkValidatorWrapperDirty(addr) return nil } @@ -1468,6 +1494,7 @@ func (db *DB) AddReward( return nil } + db.MarkValidatorWrapperDirty(snapshot.Address) rewardPool := big.NewInt(0).Set(reward) curValidator.BlockReward.Add(curValidator.BlockReward, reward) // Payout commission diff --git a/core/state/validator_dirty_test.go b/core/state/validator_dirty_test.go new file mode 100644 index 0000000000..237a84aa88 --- /dev/null +++ b/core/state/validator_dirty_test.go @@ -0,0 +1,180 @@ +package state + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/core/rawdb" + "github.com/harmony-one/harmony/numeric" + staketest "github.com/harmony-one/harmony/staking/types/test" +) + +func TestFinaliseWritesOnlyDirtyValidators(t *testing.T) { + db, _ := New(common.Hash{}, NewDatabase(rawdb.NewMemoryDatabase()), nil) + + cleanAddr := common.BytesToAddress([]byte("validator-clean")) + dirtyAddr := common.BytesToAddress([]byte("validator-dirty")) + + clean := staketest.GetDefaultValidatorWrapper() + clean.Address = cleanAddr + clean.Delegations[0].DelegatorAddress = cleanAddr + dirty := staketest.GetDefaultValidatorWrapper() + dirty.Address = dirtyAddr + dirty.Delegations[0].DelegatorAddress = dirtyAddr + + if err := db.UpdateValidatorWrapper(cleanAddr, &clean); err != nil { + t.Fatalf("UpdateValidatorWrapper clean: %v", err) + } + if err := db.UpdateValidatorWrapper(dirtyAddr, &dirty); err != nil { + t.Fatalf("UpdateValidatorWrapper dirty: %v", err) + } + db.Finalise(false) + + cleanCodeBefore := append([]byte(nil), db.GetCode(cleanAddr)...) + dirtyCodeBefore := append([]byte(nil), db.GetCode(dirtyAddr)...) + if len(cleanCodeBefore) == 0 || len(dirtyCodeBefore) == 0 { + t.Fatal("expected validator code to be present after finalize") + } + + if _, err := db.ValidatorWrapper(cleanAddr, true, false); err != nil { + t.Fatalf("load clean: %v", err) + } + dirtyWrapper, err := db.ValidatorWrapper(dirtyAddr, true, false) + if err != nil { + t.Fatalf("load dirty: %v", err) + } + if _, ok := db.stateValidatorsDirty[cleanAddr]; ok { + t.Fatal("read-only load marked clean validator dirty") + } + if _, ok := db.stateValidatorsDirty[dirtyAddr]; ok { + t.Fatal("read-only load marked dirty validator dirty") + } + + dirtyWrapper.BlockReward = big.NewInt(12345) + db.MarkValidatorWrapperDirty(dirtyAddr) + + db.Finalise(false) + + if !bytes.Equal(db.GetCode(cleanAddr), cleanCodeBefore) { + t.Fatal("clean validator was re-encoded on finalize") + } + if bytes.Equal(db.GetCode(dirtyAddr), dirtyCodeBefore) { + t.Fatal("dirty validator was not written on finalize") + } + if _, ok := db.stateValidatorsDirty[dirtyAddr]; ok { + t.Fatal("successful finalize left validator dirty") + } + + got, err := db.ValidatorWrapper(dirtyAddr, true, false) + if err != nil { + t.Fatalf("reload dirty: %v", err) + } + if got.BlockReward.Cmp(big.NewInt(12345)) != 0 { + t.Fatalf("dirty BlockReward = %v, want 12345", got.BlockReward) + } +} + +func TestAddRewardMarksValidatorDirty(t *testing.T) { + db, _ := New(common.Hash{}, NewDatabase(rawdb.NewMemoryDatabase()), nil) + + addr := common.BytesToAddress([]byte("validator-reward")) + wrapper := staketest.GetDefaultValidatorWrapper() + wrapper.Address = addr + wrapper.Delegations[0].DelegatorAddress = addr + if err := db.UpdateValidatorWrapper(addr, &wrapper); err != nil { + t.Fatalf("UpdateValidatorWrapper: %v", err) + } + db.Finalise(false) + + snapshot := staketest.GetDefaultValidatorWrapper() + snapshot.Address = addr + snapshot.Delegations[0].DelegatorAddress = addr + shares := map[common.Address]numeric.Dec{ + addr: numeric.NewDec(1), + } + if err := db.AddReward(&snapshot, big.NewInt(1000), shares); err != nil { + t.Fatalf("AddReward: %v", err) + } + if _, ok := db.stateValidatorsDirty[addr]; !ok { + t.Fatal("AddReward did not mark validator dirty") + } +} + +func TestCopyPreservesValidatorCacheAndDirtyFlags(t *testing.T) { + db, _ := New(common.Hash{}, NewDatabase(rawdb.NewMemoryDatabase()), nil) + + cleanAddr := common.BytesToAddress([]byte("copy-clean")) + dirtyAddr := common.BytesToAddress([]byte("copy-dirty")) + + clean := staketest.GetDefaultValidatorWrapper() + clean.Address = cleanAddr + clean.Delegations[0].DelegatorAddress = cleanAddr + dirty := staketest.GetDefaultValidatorWrapper() + dirty.Address = dirtyAddr + dirty.Delegations[0].DelegatorAddress = dirtyAddr + + if err := db.UpdateValidatorWrapper(cleanAddr, &clean); err != nil { + t.Fatalf("UpdateValidatorWrapper clean: %v", err) + } + if err := db.UpdateValidatorWrapper(dirtyAddr, &dirty); err != nil { + t.Fatalf("UpdateValidatorWrapper dirty: %v", err) + } + db.Finalise(false) + + if _, err := db.ValidatorWrapper(cleanAddr, true, false); err != nil { + t.Fatalf("load clean: %v", err) + } + dirtyWrapper, err := db.ValidatorWrapper(dirtyAddr, true, false) + if err != nil { + t.Fatalf("load dirty: %v", err) + } + dirtyWrapper.BlockReward = big.NewInt(7) + db.MarkValidatorWrapperDirty(dirtyAddr) + + copied := db.Copy() + if _, ok := copied.stateValidators[cleanAddr]; !ok { + t.Fatal("copy missing clean validator wrapper") + } + if _, ok := copied.stateValidators[dirtyAddr]; !ok { + t.Fatal("copy missing dirty validator wrapper") + } + if _, ok := copied.stateValidatorsDirty[dirtyAddr]; !ok { + t.Fatal("copy missing dirty flag") + } + if _, ok := copied.stateValidatorsDirty[cleanAddr]; ok { + t.Fatal("copy marked clean validator dirty") + } + if copied.stateValidators[dirtyAddr].BlockReward.Cmp(big.NewInt(7)) != 0 { + t.Fatal("copy did not preserve dirty wrapper mutation") + } + if copied.stateValidators[dirtyAddr] == db.stateValidators[dirtyAddr] { + t.Fatal("copy shared wrapper pointer with original") + } +} + +func TestCachedValidatorAddressesSurvivesFinaliseAndCopy(t *testing.T) { + db, _ := New(common.Hash{}, NewDatabase(rawdb.NewMemoryDatabase()), nil) + + addr := common.BytesToAddress([]byte("cached-validator")) + wrapper := staketest.GetDefaultValidatorWrapper() + wrapper.Address = addr + wrapper.Delegations[0].DelegatorAddress = addr + db.SetValidatorFlag(addr) + if err := db.UpdateValidatorWrapper(addr, &wrapper); err != nil { + t.Fatalf("UpdateValidatorWrapper: %v", err) + } + db.Finalise(false) + + addrs := db.CachedValidatorAddresses() + if len(addrs) != 1 || addrs[0] != addr { + t.Fatalf("CachedValidatorAddresses after Finalise = %v, want [%s]", addrs, addr.Hex()) + } + + copied := db.Copy() + copiedAddrs := copied.CachedValidatorAddresses() + if len(copiedAddrs) != 1 || copiedAddrs[0] != addr { + t.Fatalf("CachedValidatorAddresses on Copy = %v, want [%s]", copiedAddrs, addr.Hex()) + } +} diff --git a/core/state_processor.go b/core/state_processor.go index dc10469945..c2466916d7 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -554,6 +554,7 @@ func MayShardReduction(bc ChainContext, statedb *state.DB, header *block.Header) // once epoch X begins, they can terminate servers from shards 2 and 3. if curShard >= uint64(nextNumShards) || curShard != nextShard { validator.Status = effective.Inactive + statedb.MarkValidatorWrapperDirty(address) break } } diff --git a/internal/chain/engine.go b/internal/chain/engine.go index 8500361751..8c87c1e37e 100644 --- a/internal/chain/engine.go +++ b/internal/chain/engine.go @@ -428,15 +428,23 @@ func payoutUndelegations( "[Finalize] failed to get validator from state to finalize", ) } + wrapperDirty := false for i := range wrapper.Delegations { delegation := &wrapper.Delegations[i] + before := len(delegation.Undelegations) totalWithdraw := delegation.RemoveUnlockedUndelegations( header.Epoch(), wrapper.LastEpochInCommittee, lockPeriod, noEarlyUnlock, isMaxRate, ) + if len(delegation.Undelegations) != before { + wrapperDirty = true + } if totalWithdraw.Sign() != 0 { state.AddBalance(delegation.DelegatorAddress, totalWithdraw) } } + if wrapperDirty { + state.MarkValidatorWrapperDirty(validator) + } countTrack[validator] = len(wrapper.Delegations) } @@ -484,6 +492,7 @@ func setElectionEpochAndMinFee(chain engine.ChainReader, header *block.Header, s } // Set last epoch in committee wrapper.LastEpochInCommittee = newShardState.Epoch + state.MarkValidatorWrapperDirty(addr) if minRateNotZero { // Set first election epoch (applies only if previously unset) state.SetValidatorFirstElectionEpoch(addr, newShardState.Epoch) diff --git a/staking/availability/interface.go b/staking/availability/interface.go index f7df8482ef..810de2427a 100644 --- a/staking/availability/interface.go +++ b/staking/availability/interface.go @@ -27,4 +27,5 @@ type RoundHeader interface { type ValidatorState interface { ValidatorWrapper(common.Address, bool, bool) (*staking.ValidatorWrapper, error) UpdateValidatorWrapper(common.Address, *staking.ValidatorWrapper) error + MarkValidatorWrapperDirty(common.Address) } diff --git a/staking/availability/measure.go b/staking/availability/measure.go index 07f8c4e60e..5196ca34fa 100644 --- a/staking/availability/measure.go +++ b/staking/availability/measure.go @@ -119,6 +119,7 @@ func bumpCount( wrapper.Counters.NumBlocksSigned, common.Big1, ) } + state.MarkValidatorWrapperDirty(addr) } } @@ -216,6 +217,7 @@ func ComputeAndMutateEPOSStatus( switch computed.IsBelowThreshold { case missedTooManyBlocks: wrapper.Status = effective.Inactive + state.MarkValidatorWrapperDirty(addr) utils.Logger().Info(). Str("threshold", measure.String()). Interface("computed", computed). @@ -260,6 +262,7 @@ func UpdateMinimumCommissionFee( Str("firstElectionEpoch", firstElectionEpoch.String()). Msg("updating min commission rate") wrapper.Rate.SetBytes(minRate.Bytes()) + state.MarkValidatorWrapperDirty(addr) return true, nil } } @@ -268,6 +271,7 @@ func UpdateMinimumCommissionFee( type stateValidatorWrapper interface { ValidatorWrapper(addr common.Address, sendOriginal bool, copyDelegations bool) (*staking.ValidatorWrapper, error) + MarkValidatorWrapperDirty(addr common.Address) } // UpdateMaxCommissionFee makes sure the max-rate is at least higher than the rate + max-rate-change. @@ -295,6 +299,7 @@ func UpdateMaxCommissionFee(IsTopMaxRate bool, state stateValidatorWrapper, addr Str("new max-rate", newRate.String()). Msg("updating max commission rate") wrapper.MaxRate.SetBytes(newRate.Bytes()) + state.MarkValidatorWrapperDirty(addr) } return nil diff --git a/staking/availability/measure_test.go b/staking/availability/measure_test.go index 8d635f45b9..ef2a4a8cb1 100644 --- a/staking/availability/measure_test.go +++ b/staking/availability/measure_test.go @@ -660,6 +660,8 @@ func (state testStateDB) UpdateValidatorWrapper(addr common.Address, wrapper *st return nil } +func (state testStateDB) MarkValidatorWrapperDirty(addr common.Address) {} + func (state testStateDB) GetCode(addr common.Address, isValidatorCode bool) []byte { wrapper, ok := state[addr] if !ok { @@ -794,6 +796,8 @@ func (a stateValidatorWrapperImpl) ValidatorWrapper(addr common.Address, sendOri return a.v, a.err } +func (a stateValidatorWrapperImpl) MarkValidatorWrapperDirty(addr common.Address) {} + func TestUpdateMaxCommissionFee(t *testing.T) { t.Run("0.6 + 0.6 = 1 (100%)", func(t *testing.T) { v1 := stateValidatorWrapperImpl{ diff --git a/staking/slash/double-sign.go b/staking/slash/double-sign.go index 03e6dbf78a..4b93fbe3a8 100644 --- a/staking/slash/double-sign.go +++ b/staking/slash/double-sign.go @@ -403,6 +403,7 @@ func delegatorSlashApplyDebt( slashTrack *Application, useSlashExternalStakeDenomFix bool, ) error { + state.MarkValidatorWrapperDirty(current.Address) slashIndexPairs, totalStake := makeSlashList(snapshot, current) validatorDelegation := ¤t.Delegations[0] selfStakeForExternalDenom := validatorDelegation.Amount From 2f183aa0a628f5d6874d47cb8f383f9f848afe1c Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Mon, 29 Dec 2025 18:15:30 +0800 Subject: [PATCH 13/23] initial version of staking v2 --- core/staking_verifier.go | 68 +++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/core/staking_verifier.go b/core/staking_verifier.go index a428cc4c64..fd123d3c64 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -330,24 +330,68 @@ func VerifyAndDelegateFromMsg( startBalance := big.NewInt(0).Set(delegateBalance) // Start from the oldest undelegated tokens curIndex := 0 - for ; curIndex < len(delegation.Undelegations); curIndex++ { - if delegation.Undelegations[curIndex].Epoch.Cmp(epoch) >= 0 { - break + isStakingV2 := chainConfig.IsStakingV2(epoch) + + if isStakingV2 { + // Staking V2: Properly handle undelegation consumption with explicit entry removal + newUndelegations := []staking.Undelegation{} + for curIndex < len(delegation.Undelegations) { + entry := &delegation.Undelegations[curIndex] + if entry.Epoch.Cmp(epoch) >= 0 { + // Keep all remaining entries (not yet eligible for redelegation) + newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) + break + } + + if entry.Amount.Cmp(delegateBalance) <= 0 { + // Fully consume this entry + delegateBalance.Sub(delegateBalance, entry.Amount) + // Don't add to newUndelegations (fully consumed) + } else { + // Partially consume this entry + remainingAmount := big.NewInt(0).Sub(entry.Amount, delegateBalance) + newUndelegations = append(newUndelegations, staking.Undelegation{ + Amount: remainingAmount, + Epoch: entry.Epoch, + }) + delegateBalance = big.NewInt(0) + curIndex++ + // Keep all remaining entries + if curIndex < len(delegation.Undelegations) { + newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) + } + break + } + curIndex++ } - if delegation.Undelegations[curIndex].Amount.Cmp(delegateBalance) <= 0 { - delegateBalance.Sub(delegateBalance, delegation.Undelegations[curIndex].Amount) - } else { - delegation.Undelegations[curIndex].Amount.Sub( - delegation.Undelegations[curIndex].Amount, delegateBalance, - ) - delegateBalance = big.NewInt(0) - break + // Only update undelegations if something was consumed + if startBalance.Cmp(delegateBalance) > 0 { + delegation.Undelegations = newUndelegations + } + } else { + // Original logic (for backward compatibility) + for ; curIndex < len(delegation.Undelegations); curIndex++ { + if delegation.Undelegations[curIndex].Epoch.Cmp(epoch) >= 0 { + break + } + if delegation.Undelegations[curIndex].Amount.Cmp(delegateBalance) <= 0 { + delegateBalance.Sub(delegateBalance, delegation.Undelegations[curIndex].Amount) + } else { + delegation.Undelegations[curIndex].Amount.Sub( + delegation.Undelegations[curIndex].Amount, delegateBalance, + ) + delegateBalance = big.NewInt(0) + break + } } } if startBalance.Cmp(delegateBalance) > 0 { // Used undelegated token for redelegation - delegation.Undelegations = delegation.Undelegations[curIndex:] + if !isStakingV2 { + // Original logic: slice undelegations array + delegation.Undelegations = delegation.Undelegations[curIndex:] + } if err := wrapper.SanityCheck(); err != nil { return nil, nil, nil, err } From 47ccdf916031f7d156d9127c4955dd75876fbc0c Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Mon, 29 Dec 2025 18:16:01 +0800 Subject: [PATCH 14/23] add tests for staking v2, verify corner cases --- core/staking_verifier_test.go | 339 ++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 24173fbd44..e14328f28b 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -1134,6 +1134,35 @@ func makeStateForRedelegate(t *testing.T) *state.DB { return sdb } +// makeStateForRedelegateCornerCases creates state with multiple undelegation entries for corner case testing +func makeStateForRedelegateCornerCases(t *testing.T, validatorAddr common.Address, undelegations []struct { + amount *big.Int + epoch *big.Int +}) *state.DB { + sdb := makeStateDBForStake(t) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, true) + if err != nil { + t.Fatal(err) + } + + // Add delegation with multiple undelegation entries + delegation := staking.NewDelegation(delegatorAddr, new(big.Int).Set(twentyKOnes)) + for _, undel := range undelegations { + if err := delegation.Undelegate(undel.epoch, undel.amount); err != nil { + t.Fatal(err) + } + } + w.Delegations = append(w.Delegations, delegation) + + if err := sdb.UpdateValidatorWrapper(validatorAddr, w); err != nil { + t.Fatal(err) + } + + sdb.IntermediateRoot(true) + return sdb +} + func addStateUndelegationForAddr(sdb *state.DB, addr common.Address, epoch *big.Int) error { w, err := sdb.ValidatorWrapper(addr, false, true) if err != nil { @@ -1838,3 +1867,313 @@ func assertError(got, expect error) error { } return nil } + +// TestRedelegationCornerCases tests corner cases in redelegation logic that demonstrate +// bugs in the old implementation and fixes in Staking V2 +func TestRedelegationCornerCases(t *testing.T) { + epoch := big.NewInt(10) // Current epoch + epoch1 := big.NewInt(5) // Old undelegation epoch + epoch2 := big.NewInt(6) // Old undelegation epoch + epoch3 := big.NewInt(7) // Old undelegation epoch + + tests := []struct { + name string + undelegations []struct { + amount *big.Int + epoch *big.Int + } + delegateAmount *big.Int + stakingV2 bool + expectedUndelegations []struct { + amount *big.Int + epoch *big.Int + } + expectedBalanceDeducted *big.Int + description string + }{ + { + name: "CornerCase1_FullyConsumeFirst_PartiallyConsumeSecond", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch2}, // 10000 - partially consumed (need 5000, so 5000 remains) + {amount: fiveKOnes, epoch: epoch3}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Add(fiveKOnes, fiveKOnes), // 10000 total + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic bug: keeps partially consumed entry but may have issues with slice manipulation + {amount: fiveKOnes, epoch: epoch2}, // Partially consumed (should be 5000) + {amount: fiveKOnes, epoch: epoch3}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), // All from undelegations + description: "Old logic: Fully consume first entry, partially consume second. May have slice manipulation issues.", + }, + { + name: "CornerCase1_FullyConsumeFirst_PartiallyConsumeSecond_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch2}, // 10000 - partially consumed (need 5000, so 5000 remains) + {amount: fiveKOnes, epoch: epoch3}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Add(fiveKOnes, fiveKOnes), // 10000 total + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: correctly removes fully consumed, keeps partially consumed with correct amount + {amount: fiveKOnes, epoch: epoch2}, // Partially consumed (5000 remains) + {amount: fiveKOnes, epoch: epoch3}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), // All from undelegations + description: "New logic: Correctly removes fully consumed entry, keeps partially consumed with correct amount.", + }, + { + name: "CornerCase2_PartiallyConsumeFirst", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: tenKOnes, epoch: epoch1}, // 10000 - partially consumed (need 3000, so 7000 remains) + {amount: fiveKOnes, epoch: epoch2}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Mul(big.NewInt(3000), oneBig), // 3000 ONE (meets minimum) + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic: modifies entry in place, then slices + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(3000), oneBig)), epoch: epoch1}, // 7000 + {amount: fiveKOnes, epoch: epoch2}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), + description: "Old logic: Partially consumes first entry. May work but uses error-prone slice manipulation.", + }, + { + name: "CornerCase2_PartiallyConsumeFirst_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: tenKOnes, epoch: epoch1}, // 10000 - partially consumed (need 3000, so 7000 remains) + {amount: fiveKOnes, epoch: epoch2}, // 5000 - untouched + }, + delegateAmount: new(big.Int).Mul(big.NewInt(3000), oneBig), // 3000 ONE (meets minimum) + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: creates new entry with correct remaining amount + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(3000), oneBig)), epoch: epoch1}, // 7000 + {amount: fiveKOnes, epoch: epoch2}, // Untouched + }, + expectedBalanceDeducted: big.NewInt(0), + description: "New logic: Explicitly creates new entry with correct remaining amount.", + }, + { + name: "CornerCase3_FullyConsumeAll", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch3}, // 5000 - fully consumed + }, + delegateAmount: new(big.Int).Mul(big.NewInt(15000), oneBig), // 15000 ONE (all three, meets minimum) + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic: should remove all, but may have issues + }, + expectedBalanceDeducted: big.NewInt(0), + description: "Old logic: Fully consumes all entries. Should result in empty undelegations.", + }, + { + name: "CornerCase3_FullyConsumeAll_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch3}, // 5000 - fully consumed + }, + delegateAmount: new(big.Int).Mul(big.NewInt(15000), oneBig), // 15000 ONE (all three, meets minimum) + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: correctly removes all fully consumed entries + }, + expectedBalanceDeducted: big.NewInt(0), + description: "New logic: Correctly removes all fully consumed entries, results in empty undelegations.", + }, + { + name: "CornerCase4_MixedConsumption", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch3}, // 10000 - partially consumed (need 2000, so 8000 remains) + }, + delegateAmount: new(big.Int).Mul(big.NewInt(12000), oneBig), // 12000 ONE (meets minimum) + stakingV2: false, // Test old logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // Old logic: may have issues with multiple full consumptions followed by partial + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(2000), oneBig)), epoch: epoch3}, // 8000 + }, + expectedBalanceDeducted: big.NewInt(0), + description: "Old logic: Mixed full and partial consumption. May have slice manipulation issues.", + }, + { + name: "CornerCase4_MixedConsumption_V2", + undelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: fiveKOnes, epoch: epoch1}, // 5000 - fully consumed + {amount: fiveKOnes, epoch: epoch2}, // 5000 - fully consumed + {amount: tenKOnes, epoch: epoch3}, // 10000 - partially consumed (need 2000, so 8000 remains) + }, + delegateAmount: new(big.Int).Mul(big.NewInt(12000), oneBig), // 12000 ONE (meets minimum) + stakingV2: true, // Test new logic + expectedUndelegations: []struct { + amount *big.Int + epoch *big.Int + }{ + // New logic: correctly handles mixed consumption + {amount: new(big.Int).Sub(tenKOnes, new(big.Int).Mul(big.NewInt(2000), oneBig)), epoch: epoch3}, // 8000 + }, + expectedBalanceDeducted: big.NewInt(0), + description: "New logic: Correctly handles mixed full and partial consumption.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create state with undelegations + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, test.undelegations) + + // Create delegate message + msg := staking.Delegate{ + DelegatorAddress: delegatorAddr, + ValidatorAddress: validatorAddr, + Amount: new(big.Int).Set(test.delegateAmount), + } + + // Get delegation index + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{ + { + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), // Last delegation (the one with undelegations) + BlockNum: big.NewInt(100), + }, + } + + // Configure chain config + config := ¶ms.ChainConfig{} + config.MinDelegation100Epoch = big.NewInt(100) + config.RedelegationEpoch = epoch // Enable redelegation + if test.stakingV2 { + config.StakingV2Epoch = epoch // Enable Staking V2 + } else { + config.StakingV2Epoch = big.NewInt(10000000) // EpochTBD - disable Staking V2 + } + + // Execute redelegation + ws, balanceDeducted, fromLockedTokens, err := VerifyAndDelegateFromMsg( + sdb, epoch, &msg, delegationIndex, config, + ) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify balance deducted + if balanceDeducted.Cmp(test.expectedBalanceDeducted) != 0 { + t.Errorf("Balance deducted mismatch: got %v, expected %v", balanceDeducted, test.expectedBalanceDeducted) + } + + // Verify fromLockedTokens + if test.expectedBalanceDeducted.Cmp(big.NewInt(0)) == 0 { + // All from locked tokens + if len(fromLockedTokens) == 0 { + t.Errorf("Expected fromLockedTokens to be non-empty") + } else if lockedAmt, ok := fromLockedTokens[validatorAddr]; !ok { + t.Errorf("Expected fromLockedTokens to contain validatorAddr") + } else if lockedAmt.Cmp(test.delegateAmount) != 0 { + t.Errorf("FromLockedTokens amount mismatch: got %v, expected %v", lockedAmt, test.delegateAmount) + } + } + + // Verify undelegations in the result + if len(ws) == 0 { + t.Fatal("Expected at least one validator wrapper") + } + + foundDelegation := false + for _, w := range ws { + if w.Address == validatorAddr { + for _, del := range w.Delegations { + if del.DelegatorAddress == delegatorAddr { + foundDelegation = true + + // Verify undelegations + if len(del.Undelegations) != len(test.expectedUndelegations) { + t.Errorf("Undelegations count mismatch: got %d, expected %d. Description: %s", + len(del.Undelegations), len(test.expectedUndelegations), test.description) + t.Logf("Got undelegations: %+v", del.Undelegations) + t.Logf("Expected undelegations: %+v", test.expectedUndelegations) + } else { + for i, expectedUndel := range test.expectedUndelegations { + if i >= len(del.Undelegations) { + t.Errorf("Missing undelegation at index %d", i) + continue + } + actualUndel := del.Undelegations[i] + if actualUndel.Amount.Cmp(expectedUndel.amount) != 0 { + t.Errorf("Undelegation[%d] amount mismatch: got %v, expected %v. Description: %s", + i, actualUndel.Amount, expectedUndel.amount, test.description) + } + if actualUndel.Epoch.Cmp(expectedUndel.epoch) != 0 { + t.Errorf("Undelegation[%d] epoch mismatch: got %v, expected %v", + i, actualUndel.Epoch, expectedUndel.epoch) + } + } + } + break + } + } + break + } + } + + if !foundDelegation { + t.Fatal("Could not find delegation in result") + } + }) + } +} From d4282bf0f5acd68eae4cb12fec392c1d349f48e8 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Wed, 31 Dec 2025 23:39:29 +0800 Subject: [PATCH 15/23] Add batch delegation/undelegation operations for StakingV2 - Add BatchDelegate, BatchUndelegate, and UndelegateAll message types - Implement batch operations using DelegationIndex pattern (same as CollectRewards) - Add verification functions: VerifyAndBatchDelegateFromMsg, VerifyAndBatchUndelegateFromMsg, VerifyAndUndelegateAllFromMsg - Integrate batch operations into EVM context and transaction processing - Add StakingV2 epoch checks to gate new features - Update RLP encoding/decoding and transaction validation - UndelegateAll automatically finds and undelegates all active delegations --- core/blockchain_impl.go | 18 +++ core/evm.go | 190 +++++++++++++++++++++++++++++ core/staking_verifier.go | 229 +++++++++++++++++++++++++++++++++++ core/state_transition.go | 39 ++++++ core/tx_pool.go | 81 +++++++++++++ core/types/transaction.go | 16 ++- core/vm/evm.go | 6 + staking/types/messages.go | 159 ++++++++++++++++++++++++ staking/types/transaction.go | 6 + 9 files changed, 741 insertions(+), 3 deletions(-) diff --git a/core/blockchain_impl.go b/core/blockchain_impl.go index f0e7e237ff..ea75b76f59 100644 --- a/core/blockchain_impl.go +++ b/core/blockchain_impl.go @@ -3228,6 +3228,24 @@ func (bc *BlockChainImpl) prepareStakingMetaData( case staking.DirectiveUndelegate: case staking.DirectiveCollectRewards: + case staking.DirectiveBatchDelegate: + batchDelegate := decodePayload.(*staking.BatchDelegate) + for _, delegationAction := range batchDelegate.Delegations { + delegate := &staking.Delegate{ + DelegatorAddress: batchDelegate.DelegatorAddress, + ValidatorAddress: delegationAction.ValidatorAddress, + Amount: delegationAction.Amount, + } + if err := processDelegateMetadata(delegate, + newDelegations, + state, + bc, + blockNum); err != nil { + return nil, nil, err + } + } + case staking.DirectiveBatchUndelegate: + case staking.DirectiveUndelegateAll: default: } } diff --git a/core/evm.go b/core/evm.go index f8e94067df..fdfa1dd50f 100644 --- a/core/evm.go +++ b/core/evm.go @@ -93,6 +93,9 @@ func NewEVMBlockContext(msg Message, header *block.Header, chain ChainContext, a Delegate: DelegateFn(header, chain), Undelegate: UndelegateFn(header, chain), CollectRewards: CollectRewardsFn(header, chain), + BatchDelegate: BatchDelegateFn(header, chain), + BatchUndelegate: BatchUndelegateFn(header, chain), + UndelegateAll: UndelegateAllFn(header, chain), CalculateMigrationGas: CalculateMigrationGasFn(chain), ShardID: chain.ShardID(), NumShards: shard.Schedule.InstanceForEpoch(header.Epoch()).NumShards(), @@ -329,6 +332,193 @@ func CollectRewardsFn(ref *block.Header, chain ChainContext) vm.CollectRewardsFu } } +func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc { + return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, batchDelegate *stakingTypes.BatchDelegate) error { + delegations, err := chain.ReadDelegationsByDelegatorAt(batchDelegate.DelegatorAddress, big.NewInt(0).Sub(ref.Number(), big.NewInt(1))) + if err != nil { + return err + } + updatedValidatorWrappers, balanceToBeDeducted, fromLockedTokens, err := VerifyAndBatchDelegateFromMsg( + db, ref.Epoch(), batchDelegate, delegations, chain.Config()) + if err != nil { + return err + } + for _, wrapper := range updatedValidatorWrappers { + if err := db.UpdateValidatorWrapperWithRevert(wrapper.Address, wrapper); err != nil { + return err + } + } + + db.SubBalance(batchDelegate.DelegatorAddress, balanceToBeDeducted) + + if rosettaTracer != nil && balanceToBeDeducted.Sign() != 0 { + for _, delegationAction := range batchDelegate.Delegations { + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + }, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &delegationAction.ValidatorAddress, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + delegationAction.Amount, + ) + } + } + + if len(fromLockedTokens) > 0 { + sortedKeys := []common.Address{} + for key := range fromLockedTokens { + sortedKeys = append(sortedKeys, key) + } + sort.SliceStable(sortedKeys, func(i, j int) bool { + return bytes.Compare(sortedKeys[i][:], sortedKeys[j][:]) < 0 + }) + for _, key := range sortedKeys { + redelegatedToken, ok := fromLockedTokens[key] + if !ok { + return errors.New("Key missing for delegation receipt") + } + encodedRedelegationData := []byte{} + addrBytes := key.Bytes() + encodedRedelegationData = append(encodedRedelegationData, addrBytes...) + encodedRedelegationData = append(encodedRedelegationData, redelegatedToken.Bytes()...) + db.AddLog(&types.Log{ + Address: batchDelegate.DelegatorAddress, + Topics: []common.Hash{staking.DelegateTopic}, + Data: encodedRedelegationData, + BlockNumber: ref.Number().Uint64(), + }) + + if rosettaTracer != nil { + fromAccount := common.BytesToAddress(key.Bytes()) + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &fromAccount, + Metadata: map[string]interface{}{"type": "undelegation"}, + }, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &fromAccount, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + redelegatedToken, + ) + } + } + } + return nil + } +} + +func BatchUndelegateFn(ref *block.Header, chain ChainContext) vm.BatchUndelegateFunc { + return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, batchUndelegate *stakingTypes.BatchUndelegate) error { + updatedValidatorWrappers, err := VerifyAndBatchUndelegateFromMsg(db, ref.Epoch(), batchUndelegate) + if err != nil { + return err + } + + for _, wrapper := range updatedValidatorWrappers { + if err := db.UpdateValidatorWrapperWithRevert(wrapper.Address, wrapper); err != nil { + return err + } + } + + if rosettaTracer != nil { + for i, delegationIndex := range batchUndelegate.DelegationIndexes { + amount := batchUndelegate.Amounts[i] + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchUndelegate.DelegatorAddress, + SubAccount: &delegationIndex.ValidatorAddress, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + &vm.RosettaLogAddressItem{ + Account: &batchUndelegate.DelegatorAddress, + SubAccount: &delegationIndex.ValidatorAddress, + Metadata: map[string]interface{}{"type": "undelegation"}, + }, + amount, + ) + } + } + + return nil + } +} + +func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc { + return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, undelegateAll *stakingTypes.UndelegateAll) error { + if chain == nil { + return errors.New("[UndelegateAll] No chain context provided") + } + delegations, err := chain.ReadDelegationsByDelegatorAt(undelegateAll.DelegatorAddress, big.NewInt(0).Sub(ref.Number(), big.NewInt(1))) + if err != nil { + return err + } + + // Track original amounts before undelegation for rosetta logging + originalAmounts := map[common.Address]*big.Int{} + for _, delegationIndex := range delegations { + if !db.IsValidator(delegationIndex.ValidatorAddress) { + continue + } + wrapper, err := db.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) + if err != nil { + continue + } + if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { + continue + } + delegation := &wrapper.Delegations[delegationIndex.Index] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), undelegateAll.DelegatorAddress.Bytes()) { + continue + } + if delegation.Amount.Cmp(common.Big0) > 0 { + originalAmounts[delegationIndex.ValidatorAddress] = new(big.Int).Set(delegation.Amount) + } + } + + updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( + db, ref.Epoch(), undelegateAll, delegations, + ) + if err != nil { + return err + } + for _, wrapper := range updatedValidatorWrappers { + if err := db.UpdateValidatorWrapperWithRevert(wrapper.Address, wrapper); err != nil { + return err + } + } + + if rosettaTracer != nil { + for validatorAddr, amount := range originalAmounts { + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &undelegateAll.DelegatorAddress, + SubAccount: &validatorAddr, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + &vm.RosettaLogAddressItem{ + Account: &undelegateAll.DelegatorAddress, + SubAccount: &validatorAddr, + Metadata: map[string]interface{}{"type": "undelegation"}, + }, + amount, + ) + } + } + + return nil + } +} + //func MigrateDelegationsFn(ref *block.Header, chain ChainContext) vm.MigrateDelegationsFunc { // return func(db vm.StateDB, migrationMsg *stakingTypes.MigrationMsg) ([]interface{}, error) { // // get existing delegations diff --git a/core/staking_verifier.go b/core/staking_verifier.go index fd123d3c64..1c0f610a01 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -510,6 +510,235 @@ func VerifyAndUndelegateFromMsg( return nil, errNoDelegationToUndelegate } +// VerifyAndBatchDelegateFromMsg verifies batch delegation message using the stateDB +// and returns all updated validator wrappers, total balance to be deducted, and locked tokens map. +// +// Note that this function never updates the stateDB, it only reads from stateDB. +func VerifyAndBatchDelegateFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchDelegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, +) ([]*staking.ValidatorWrapper, *big.Int, map[common.Address]*big.Int, error) { + if stateDB == nil { + return nil, nil, nil, errStateDBIsMissing + } + if epoch == nil { + return nil, nil, nil, errEpochMissing + } + if chainConfig == nil { + return nil, nil, nil, errors.New("chain config is required") + } + if !chainConfig.IsStakingV2(epoch) { + return nil, nil, nil, errors.New("batch delegation is only available in StakingV2 epoch") + } + if len(msg.Delegations) == 0 { + return nil, nil, nil, errors.New("batch delegation must contain at least one delegation") + } + + allUpdatedWrappers := []*staking.ValidatorWrapper{} + totalBalanceToDeduct := big.NewInt(0) + allFromLockedTokens := map[common.Address]*big.Int{} + wrapperMap := map[common.Address]*staking.ValidatorWrapper{} + + for _, delegationAction := range msg.Delegations { + if !stateDB.IsValidator(delegationAction.ValidatorAddress) { + return nil, nil, nil, errValidatorNotExist + } + if delegationAction.Amount == nil || delegationAction.Amount.Sign() == -1 { + return nil, nil, nil, errNegativeAmount + } + if delegationAction.Amount.Cmp(minimumDelegation) < 0 { + if chainConfig.IsMinDelegation100(epoch) { + if delegationAction.Amount.Cmp(minimumDelegationV2) < 0 { + return nil, nil, nil, errDelegationTooSmallV2 + } + } else { + return nil, nil, nil, errDelegationTooSmall + } + } + + delegateMsg := &staking.Delegate{ + DelegatorAddress: msg.DelegatorAddress, + ValidatorAddress: delegationAction.ValidatorAddress, + Amount: delegationAction.Amount, + } + + updatedWrappers, balanceToDeduct, fromLockedTokens, err := VerifyAndDelegateFromMsg( + stateDB, epoch, delegateMsg, delegations, chainConfig, + ) + if err != nil { + return nil, nil, nil, err + } + + for _, wrapper := range updatedWrappers { + if existingWrapper, exists := wrapperMap[wrapper.Address]; exists { + if existingWrapper != wrapper { + return nil, nil, nil, errors.New("duplicate validator wrapper in batch delegation") + } + } else { + wrapperMap[wrapper.Address] = wrapper + allUpdatedWrappers = append(allUpdatedWrappers, wrapper) + } + } + + totalBalanceToDeduct.Add(totalBalanceToDeduct, balanceToDeduct) + + for validatorAddr, amount := range fromLockedTokens { + if existingAmount, exists := allFromLockedTokens[validatorAddr]; exists { + allFromLockedTokens[validatorAddr] = new(big.Int).Add(existingAmount, amount) + } else { + allFromLockedTokens[validatorAddr] = new(big.Int).Set(amount) + } + } + } + + if totalBalanceToDeduct.Cmp(big.NewInt(0)) > 0 { + if !CanTransfer(stateDB, msg.DelegatorAddress, totalBalanceToDeduct) { + return nil, nil, nil, errors.Wrapf( + errInsufficientBalanceForStake, "insufficient balance for batch delegation: %v", + totalBalanceToDeduct, + ) + } + } + + return allUpdatedWrappers, totalBalanceToDeduct, allFromLockedTokens, nil +} + +// VerifyAndBatchUndelegateFromMsg verifies batch undelegation message using the stateDB +// and returns all updated validator wrappers. +// +// Note that this function never updates the stateDB, it only reads from stateDB. +func VerifyAndBatchUndelegateFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchUndelegate, +) ([]*staking.ValidatorWrapper, error) { + if stateDB == nil { + return nil, errStateDBIsMissing + } + if epoch == nil { + return nil, errEpochMissing + } + if len(msg.DelegationIndexes) == 0 { + return nil, errors.New("batch undelegation must contain at least one delegation index") + } + if len(msg.DelegationIndexes) != len(msg.Amounts) { + return nil, errors.New("delegation indexes and amounts must have the same length") + } + + allUpdatedWrappers := []*staking.ValidatorWrapper{} + wrapperMap := map[common.Address]*staking.ValidatorWrapper{} + + for i, delegationIndex := range msg.DelegationIndexes { + amount := msg.Amounts[i] + if amount == nil || amount.Sign() == -1 { + return nil, errNegativeAmount + } + + if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { + return nil, errValidatorNotExist + } + + var wrapper *staking.ValidatorWrapper + var exists bool + if wrapper, exists = wrapperMap[delegationIndex.ValidatorAddress]; !exists { + var err error + wrapper, err = stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, true) + if err != nil { + return nil, err + } + wrapperMap[delegationIndex.ValidatorAddress] = wrapper + } + + if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { + utils.Logger().Warn(). + Str("validator", delegationIndex.ValidatorAddress.String()). + Uint64("delegation index", delegationIndex.Index). + Int("delegations length", len(wrapper.Delegations)). + Msg("Delegation index out of bound") + return nil, errors.New("Delegation index out of bound") + } + + delegation := &wrapper.Delegations[delegationIndex.Index] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + return nil, errors.New("delegator address mismatch") + } + + if err := delegation.Undelegate(epoch, amount); err != nil { + return nil, err + } + } + + for _, wrapper := range wrapperMap { + if err := wrapper.SanityCheck(); err != nil { + if errors.Cause(err) == staking.ErrInvalidSelfDelegation { + wrapper.Status = effective.Inactive + } else { + return nil, err + } + } + allUpdatedWrappers = append(allUpdatedWrappers, wrapper) + } + + return allUpdatedWrappers, nil +} + +// VerifyAndUndelegateAllFromMsg verifies and prepares undelegation of all delegations +// for a delegator. It reads all delegations and creates a batch undelegation. +// +// Note that this function never updates the stateDB, it only reads from stateDB. +func VerifyAndUndelegateAllFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, +) ([]*staking.ValidatorWrapper, error) { + if stateDB == nil { + return nil, errStateDBIsMissing + } + if epoch == nil { + return nil, errEpochMissing + } + if len(delegations) == 0 { + return nil, errors.New("no delegations to undelegate") + } + + delegationIndexes := []staking.DelegationIndex{} + amounts := []*big.Int{} + + for _, delegationIndex := range delegations { + if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { + continue + } + + wrapper, err := stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) + if err != nil { + return nil, err + } + + if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { + continue + } + + delegation := &wrapper.Delegations[delegationIndex.Index] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + continue + } + + if delegation.Amount.Cmp(common.Big0) <= 0 { + continue + } + + delegationIndexes = append(delegationIndexes, delegationIndex) + amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + } + + if len(delegationIndexes) == 0 { + return nil, errors.New("no active delegations to undelegate") + } + + batchUndelegateMsg := &staking.BatchUndelegate{ + DelegatorAddress: msg.DelegatorAddress, + DelegationIndexes: delegationIndexes, + Amounts: amounts, + } + + return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg) +} + // VerifyAndMigrateFromMsg verifies and transfers all delegations of // msg.From to msg.To. Returns all modified validator wrappers and delegate msgs // for metadata diff --git a/core/state_transition.go b/core/state_transition.go index 1decefd18b..b688526d2a 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -420,6 +420,45 @@ func (st *StateTransition) StakingTransitionDb() (usedGas uint64, err error) { return 0, errInvalidSigner } err = st.evm.Context.CollectRewards(st.evm.StateDB, nil, stkMsg) + case types.BatchDelegate: + if !st.evm.ChainConfig().IsStakingV2(st.evm.Context.EpochNumber) { + return 0, errors.New("batch delegation is only available in StakingV2 epoch") + } + stkMsg := &stakingTypes.BatchDelegate{} + if err = rlp.DecodeBytes(msg.Data(), stkMsg); err != nil { + return 0, err + } + utils.Logger().Info().Msgf("[DEBUG STAKING] staking type: %s, gas: %d, txn: %+v", msg.Type(), gas, stkMsg) + if msg.From() != stkMsg.DelegatorAddress { + return 0, errInvalidSigner + } + err = st.evm.Context.BatchDelegate(st.evm.StateDB, nil, stkMsg) + case types.BatchUndelegate: + if !st.evm.ChainConfig().IsStakingV2(st.evm.Context.EpochNumber) { + return 0, errors.New("batch undelegation is only available in StakingV2 epoch") + } + stkMsg := &stakingTypes.BatchUndelegate{} + if err = rlp.DecodeBytes(msg.Data(), stkMsg); err != nil { + return 0, err + } + utils.Logger().Info().Msgf("[DEBUG STAKING] staking type: %s, gas: %d, txn: %+v", msg.Type(), gas, stkMsg) + if msg.From() != stkMsg.DelegatorAddress { + return 0, errInvalidSigner + } + err = st.evm.Context.BatchUndelegate(st.evm.StateDB, nil, stkMsg) + case types.UndelegateAll: + if !st.evm.ChainConfig().IsStakingV2(st.evm.Context.EpochNumber) { + return 0, errors.New("undelegate all is only available in StakingV2 epoch") + } + stkMsg := &stakingTypes.UndelegateAll{} + if err = rlp.DecodeBytes(msg.Data(), stkMsg); err != nil { + return 0, err + } + utils.Logger().Info().Msgf("[DEBUG STAKING] staking type: %s, gas: %d, txn: %+v", msg.Type(), gas, stkMsg) + if msg.From() != stkMsg.DelegatorAddress { + return 0, errInvalidSigner + } + err = st.evm.Context.UndelegateAll(st.evm.StateDB, nil, stkMsg) default: return 0, stakingTypes.ErrInvalidStakingKind } diff --git a/core/tx_pool.go b/core/tx_pool.go index 53801fad94..1053a0d3f7 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -907,6 +907,87 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { _, _, err = VerifyAndCollectRewardsFromDelegation(pool.currentState, delegations) return err + case staking.DirectiveBatchDelegate: + pendingEpoch := pool.pendingEpoch() + if !pool.chainconfig.IsStakingV2(pendingEpoch) { + return errors.New("batch delegation is only available in StakingV2 epoch") + } + msg, err := staking.RLPDecodeStakeMsg(tx.Data(), staking.DirectiveBatchDelegate) + if err != nil { + return err + } + stkMsg, ok := msg.(*staking.BatchDelegate) + if !ok { + return ErrInvalidMsgForStakingDirective + } + if from != stkMsg.DelegatorAddress { + return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) + } + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(stkMsg.DelegatorAddress) + if err != nil { + return err + } + _, delegateAmt, _, err := VerifyAndBatchDelegateFromMsg( + pool.currentState, pendingEpoch, stkMsg, delegations, pool.chainconfig) + if err != nil { + return err + } + gasAmt := new(big.Int).Mul(tx.GasPrice(), new(big.Int).SetUint64(tx.GasLimit())) + totalAmt := new(big.Int).Add(delegateAmt, gasAmt) + if bal := pool.currentState.GetBalance(from); bal.Cmp(totalAmt) < 0 { + return fmt.Errorf("not enough balance for batch delegation: %v < %v", bal, delegateAmt) + } + return nil + case staking.DirectiveBatchUndelegate: + pendingEpoch := pool.pendingEpoch() + if !pool.chainconfig.IsStakingV2(pendingEpoch) { + return errors.New("batch undelegation is only available in StakingV2 epoch") + } + msg, err := staking.RLPDecodeStakeMsg(tx.Data(), staking.DirectiveBatchUndelegate) + if err != nil { + return err + } + stkMsg, ok := msg.(*staking.BatchUndelegate) + if !ok { + return ErrInvalidMsgForStakingDirective + } + if from != stkMsg.DelegatorAddress { + return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) + } + _, err = VerifyAndBatchUndelegateFromMsg(pool.currentState, pendingEpoch, stkMsg) + return err + case staking.DirectiveUndelegateAll: + pendingEpoch := pool.pendingEpoch() + if !pool.chainconfig.IsStakingV2(pendingEpoch) { + return errors.New("undelegate all is only available in StakingV2 epoch") + } + msg, err := staking.RLPDecodeStakeMsg(tx.Data(), staking.DirectiveUndelegateAll) + if err != nil { + return err + } + stkMsg, ok := msg.(*staking.UndelegateAll) + if !ok { + return ErrInvalidMsgForStakingDirective + } + if from != stkMsg.DelegatorAddress { + return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) + } + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(stkMsg.DelegatorAddress) + if err != nil { + return err + } + _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations) + return err default: return staking.ErrInvalidStakingKind } diff --git a/core/types/transaction.go b/core/types/transaction.go index 1364ccfac5..bc0a89dd3a 100644 --- a/core/types/transaction.go +++ b/core/types/transaction.go @@ -63,12 +63,22 @@ const ( Delegate Undelegate CollectRewards + BatchDelegate + BatchUndelegate + UndelegateAll ) // StakingTypeMap is the map from staking type to transactionType -var StakingTypeMap = map[staking.Directive]TransactionType{staking.DirectiveCreateValidator: StakeCreateVal, - staking.DirectiveEditValidator: StakeEditVal, staking.DirectiveDelegate: Delegate, - staking.DirectiveUndelegate: Undelegate, staking.DirectiveCollectRewards: CollectRewards} +var StakingTypeMap = map[staking.Directive]TransactionType{ + staking.DirectiveCreateValidator: StakeCreateVal, + staking.DirectiveEditValidator: StakeEditVal, + staking.DirectiveDelegate: Delegate, + staking.DirectiveUndelegate: Undelegate, + staking.DirectiveCollectRewards: CollectRewards, + staking.DirectiveBatchDelegate: BatchDelegate, + staking.DirectiveBatchUndelegate: BatchUndelegate, + staking.DirectiveUndelegateAll: UndelegateAll, +} // InternalTransaction defines the common interface for harmony and ethereum transactions. type InternalTransaction interface { diff --git a/core/vm/evm.go b/core/vm/evm.go index 9f64457ab0..80183b9e25 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -61,6 +61,9 @@ type ( DelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.Delegate) error UndelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.Undelegate) error CollectRewardsFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.CollectRewards) error + BatchDelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.BatchDelegate) error + BatchUndelegateFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.BatchUndelegate) error + UndelegateAllFunc func(db StateDB, rosettaTracer RosettaTracer, stakeMsg *stakingTypes.UndelegateAll) error // Used for migrating delegations via the staking precompile //MigrateDelegationsFunc func(db StateDB, migrationMsg *stakingTypes.MigrationMsg) ([]interface{}, error) CalculateMigrationGasFunc func(db StateDB, migrationMsg *stakingTypes.MigrationMsg, homestead bool, istanbul bool, isEIP3860 bool) (uint64, error) @@ -180,6 +183,9 @@ type BlockContext struct { Delegate DelegateFunc Undelegate UndelegateFunc CollectRewards CollectRewardsFunc + BatchDelegate BatchDelegateFunc + BatchUndelegate BatchUndelegateFunc + UndelegateAll UndelegateAllFunc CalculateMigrationGas CalculateMigrationGasFunc ShardID uint32 // Used by staking and cross shard transfer precompile diff --git a/staking/types/messages.go b/staking/types/messages.go index bddcbacf0b..1102f83868 100644 --- a/staking/types/messages.go +++ b/staking/types/messages.go @@ -27,6 +27,12 @@ const ( DirectiveUndelegate // DirectiveCollectRewards ... DirectiveCollectRewards + // DirectiveBatchDelegate ... + DirectiveBatchDelegate + // DirectiveBatchUndelegate ... + DirectiveBatchUndelegate + // DirectiveUndelegateAll ... + DirectiveUndelegateAll ) var ( @@ -36,6 +42,9 @@ var ( DirectiveDelegate: "Delegate", DirectiveUndelegate: "Undelegate", DirectiveCollectRewards: "CollectRewards", + DirectiveBatchDelegate: "BatchDelegate", + DirectiveBatchUndelegate: "BatchUndelegate", + DirectiveUndelegateAll: "UndelegateAll", } // ErrInvalidStakingKind given when caller gives bad staking message kind ErrInvalidStakingKind = errors.New("bad staking kind") @@ -263,3 +272,153 @@ func (v MigrationMsg) Copy() MigrationMsg { func (v MigrationMsg) Equals(s MigrationMsg) bool { return v.From == s.From && v.To == s.To } + +// DelegationAction represents a single delegation action in a batch operation +type DelegationAction struct { + ValidatorAddress common.Address `json:"validator_address"` + Amount *big.Int `json:"amount"` +} + +// BatchDelegate - type for delegating to multiple validators in one transaction +type BatchDelegate struct { + DelegatorAddress common.Address `json:"delegator_address"` + Delegations []DelegationAction `json:"delegations"` +} + +// Type of BatchDelegate +func (v BatchDelegate) Type() Directive { + return DirectiveBatchDelegate +} + +// Copy returns a deep copy of the BatchDelegate as a StakeMsg interface +func (v BatchDelegate) Copy() StakeMsg { + cp := BatchDelegate{ + DelegatorAddress: v.DelegatorAddress, + Delegations: make([]DelegationAction, len(v.Delegations)), + } + for i, d := range v.Delegations { + cp.Delegations[i] = DelegationAction{ + ValidatorAddress: d.ValidatorAddress, + } + if d.Amount != nil { + cp.Delegations[i].Amount = new(big.Int).Set(d.Amount) + } + } + return cp +} + +// Equals returns if v and s are equal +func (v BatchDelegate) Equals(s BatchDelegate) bool { + if !bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) { + return false + } + if len(v.Delegations) != len(s.Delegations) { + return false + } + for i := range v.Delegations { + if !bytes.Equal(v.Delegations[i].ValidatorAddress.Bytes(), s.Delegations[i].ValidatorAddress.Bytes()) { + return false + } + if v.Delegations[i].Amount == nil { + if s.Delegations[i].Amount != nil { + return false + } + } else if s.Delegations[i].Amount == nil { + return false + } else if v.Delegations[i].Amount.Cmp(s.Delegations[i].Amount) != 0 { + return false + } + } + return true +} + +// BatchUndelegate - type for undelegating from multiple validators in one transaction +type BatchUndelegate struct { + DelegatorAddress common.Address `json:"delegator_address"` + DelegationIndexes []DelegationIndex `json:"delegation_indexes"` + Amounts []*big.Int `json:"amounts"` +} + +// Type of BatchUndelegate +func (v BatchUndelegate) Type() Directive { + return DirectiveBatchUndelegate +} + +// Copy returns a deep copy of the BatchUndelegate as a StakeMsg interface +func (v BatchUndelegate) Copy() StakeMsg { + cp := BatchUndelegate{ + DelegatorAddress: v.DelegatorAddress, + DelegationIndexes: make([]DelegationIndex, len(v.DelegationIndexes)), + Amounts: make([]*big.Int, len(v.Amounts)), + } + for i, idx := range v.DelegationIndexes { + cp.DelegationIndexes[i] = DelegationIndex{ + ValidatorAddress: idx.ValidatorAddress, + Index: idx.Index, + } + if idx.BlockNum != nil { + cp.DelegationIndexes[i].BlockNum = new(big.Int).Set(idx.BlockNum) + } + } + for i, amt := range v.Amounts { + if amt != nil { + cp.Amounts[i] = new(big.Int).Set(amt) + } + } + return cp +} + +// Equals returns if v and s are equal +func (v BatchUndelegate) Equals(s BatchUndelegate) bool { + if !bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) { + return false + } + if len(v.DelegationIndexes) != len(s.DelegationIndexes) { + return false + } + if len(v.Amounts) != len(s.Amounts) { + return false + } + for i := range v.DelegationIndexes { + if !bytes.Equal(v.DelegationIndexes[i].ValidatorAddress.Bytes(), s.DelegationIndexes[i].ValidatorAddress.Bytes()) { + return false + } + if v.DelegationIndexes[i].Index != s.DelegationIndexes[i].Index { + return false + } + } + for i := range v.Amounts { + if v.Amounts[i] == nil { + if s.Amounts[i] != nil { + return false + } + } else if s.Amounts[i] == nil { + return false + } else if v.Amounts[i].Cmp(s.Amounts[i]) != 0 { + return false + } + } + return true +} + +// UndelegateAll - type for undelegating all from all validators +type UndelegateAll struct { + DelegatorAddress common.Address `json:"delegator_address"` +} + +// Type of UndelegateAll +func (v UndelegateAll) Type() Directive { + return DirectiveUndelegateAll +} + +// Copy returns a deep copy of the UndelegateAll as a StakeMsg interface +func (v UndelegateAll) Copy() StakeMsg { + return UndelegateAll{ + DelegatorAddress: v.DelegatorAddress, + } +} + +// Equals returns if v and s are equal +func (v UndelegateAll) Equals(s UndelegateAll) bool { + return bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) +} diff --git a/staking/types/transaction.go b/staking/types/transaction.go index c9923bcdfe..0d011bcd74 100644 --- a/staking/types/transaction.go +++ b/staking/types/transaction.go @@ -299,6 +299,12 @@ func RLPDecodeStakeMsg(payload []byte, d Directive) (interface{}, error) { ds = &Undelegate{} case DirectiveCollectRewards: ds = &CollectRewards{} + case DirectiveBatchDelegate: + ds = &BatchDelegate{} + case DirectiveBatchUndelegate: + ds = &BatchUndelegate{} + case DirectiveUndelegateAll: + ds = &UndelegateAll{} default: return nil, nil } From 635921ab3767da558fa1497951445020a04c007a Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Wed, 31 Dec 2025 23:54:09 +0800 Subject: [PATCH 16/23] Add tests for batch delegation/undelegation operations - Add TestVerifyAndBatchDelegateFromMsg with comprehensive test cases - Add TestVerifyAndBatchUndelegateFromMsg with error cases - Add TestVerifyAndUndelegateAllFromMsg for undelegate all functionality - Tests follow same pattern as existing delegate/undelegate tests - Include StakingV2 epoch validation tests --- core/staking_verifier_test.go | 482 ++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index e14328f28b..d9be5f2c8a 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -2177,3 +2177,485 @@ func TestRedelegationCornerCases(t *testing.T) { }) } } + +func TestVerifyAndBatchDelegateFromMsg(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + stakingV2Epoch := big.NewInt(defaultEpoch) + + tests := []struct { + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchDelegate + delegations []staking.DelegationIndex + chainConfig *params.ChainConfig + + expVWrappers []staking.ValidatorWrapper + expAmt *big.Int + expRedel map[common.Address]*big.Int + expErr error + }{ + { + name: "successful batch delegate to two validators", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + {ValidatorAddress: validatorAddr2, Amount: new(big.Int).Set(fiveKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + config.MinDelegation100Epoch = big.NewInt(100) + return config + }(), + expVWrappers: func() []staking.ValidatorWrapper { + w1 := makeVWrapperByIndex(validatorIndex) + w1.Delegations = append(w1.Delegations, staking.NewDelegation(delegatorAddr, tenKOnes)) + w2 := makeVWrapperByIndex(validator2Index) + w2.Delegations = append(w2.Delegations, staking.NewDelegation(delegatorAddr, fiveKOnes)) + return []staking.ValidatorWrapper{w1, w2} + }(), + expAmt: new(big.Int).Add(tenKOnes, fiveKOnes), + }, + { + name: "nil state db", + sdb: nil, + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errStateDBIsMissing, + }, + { + name: "nil epoch", + sdb: makeStateDBForStake(t), + epoch: nil, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errEpochMissing, + }, + { + name: "not StakingV2 epoch", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = big.NewInt(10000000) // Disabled + return config + }(), + expErr: errors.New("batch delegation is only available in StakingV2 epoch"), + }, + { + name: "empty delegations", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{}, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errors.New("batch delegation must contain at least one delegation"), + }, + { + name: "invalid validator", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: makeTestAddr("not exist"), Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errValidatorNotExist, + }, + { + name: "negative amount", + sdb: makeStateDBForStake(t), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: big.NewInt(-1)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errNegativeAmount, + }, + { + name: "insufficient balance", + sdb: func() *state.DB { + sdb := makeStateDBForStake(t) + sdb.SetBalance(delegatorAddr, big.NewInt(100)) + return sdb + }(), + epoch: epoch, + msg: staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + }, + delegations: makeMsgCollectRewards(), + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = stakingV2Epoch + return config + }(), + expErr: errInsufficientBalanceForStake, + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + ws, amt, amtRedel, err := VerifyAndBatchDelegateFromMsg( + test.sdb, test.epoch, &test.msg, test.delegations, test.chainConfig, + ) + + if assErr := assertError(err, test.expErr); assErr != nil { + t.Errorf("Test %v: %v", i, assErr) + } + if err != nil || test.expErr != nil { + return + } + + if amt.Cmp(test.expAmt) != 0 { + t.Errorf("Test %v: unexpected amount %v / %v", i, amt, test.expAmt) + } + + if len(amtRedel) != len(test.expRedel) { + t.Errorf("Test %v: wrong expected redelegation length %d / %d", i, len(amtRedel), len(test.expRedel)) + } else { + for key, value := range test.expRedel { + actValue, ok := amtRedel[key] + if !ok { + t.Errorf("Test %v: missing expected redelegation key/value %v / %v", i, key, value) + } + if value.Cmp(actValue) != 0 { + t.Errorf("Test %v: unexpected redelegation value %v / %v", i, actValue, value) + } + } + } + + if len(ws) != len(test.expVWrappers) { + t.Errorf("Test %v: wrong wrapper count %d / %d", i, len(ws), len(test.expVWrappers)) + return + } + + for j := range ws { + if err := staketest.CheckValidatorWrapperEqual(*ws[j], test.expVWrappers[j]); err != nil { + t.Errorf("Test %v wrapper %v: %v", i, j, err) + } + } + }) + } +} + +func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + + tests := []struct { + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchUndelegate + expErr error + }{ + { + name: "successful batch undelegate from two validators", + sdb: func() *state.DB { + sdb := makeDefaultStateForUndelegate(t) + w2 := makeVWrapperByIndex(validator2Index) + newDelegation2 := staking.NewDelegation(delegatorAddr, new(big.Int).Set(twentyKOnes)) + w2.Delegations = append(w2.Delegations, newDelegation2) + if err := sdb.UpdateValidatorWrapper(validatorAddr2, &w2); err != nil { + t.Fatal(err) + } + sdb.IntermediateRoot(true) + return sdb + }(), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{ + new(big.Int).Set(fiveKOnes), + new(big.Int).Set(fiveKOnes), + }, + }, + }, + { + name: "nil state db", + sdb: nil, + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errStateDBIsMissing, + }, + { + name: "nil epoch", + sdb: makeDefaultStateForUndelegate(t), + epoch: nil, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errEpochMissing, + }, + { + name: "empty delegation indexes", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{}, + Amounts: []*big.Int{}, + }, + expErr: errors.New("batch undelegation must contain at least one delegation index"), + }, + { + name: "mismatched lengths", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("delegation indexes and amounts must have the same length"), + }, + { + name: "invalid validator", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: makeTestAddr("not exist"), Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errValidatorNotExist, + }, + { + name: "negative amount", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{big.NewInt(-1)}, + }, + expErr: errNegativeAmount, + }, + { + name: "delegation index out of bound", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 999, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("Delegation index out of bound"), + }, + { + name: "delegator address mismatch", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.BatchUndelegate{ + DelegatorAddress: makeTestAddr("wrong delegator"), + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("delegator address mismatch"), + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + ws, err := VerifyAndBatchUndelegateFromMsg(test.sdb, test.epoch, &test.msg) + + if assErr := assertError(err, test.expErr); assErr != nil { + t.Errorf("Test %v: %v", i, assErr) + } + if err != nil || test.expErr != nil { + return + } + + if len(ws) == 0 { + t.Errorf("Test %v: expected at least one wrapper", i) + } + }) + } +} + +func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + + tests := []struct { + name string + sdb vm.StateDB + epoch *big.Int + msg staking.UndelegateAll + delegations []staking.DelegationIndex + expErr error + }{ + { + name: "successful undelegate all", + sdb: func() *state.DB { + sdb := makeDefaultStateForUndelegate(t) + w2 := makeVWrapperByIndex(validator2Index) + newDelegation2 := staking.NewDelegation(delegatorAddr, new(big.Int).Set(twentyKOnes)) + w2.Delegations = append(w2.Delegations, newDelegation2) + if err := sdb.UpdateValidatorWrapper(validatorAddr2, &w2); err != nil { + t.Fatal(err) + } + sdb.IntermediateRoot(true) + return sdb + }(), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: func() []staking.DelegationIndex { + return []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, + } + }(), + }, + { + name: "nil state db", + sdb: nil, + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + expErr: errStateDBIsMissing, + }, + { + name: "nil epoch", + sdb: makeDefaultStateForUndelegate(t), + epoch: nil, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + expErr: errEpochMissing, + }, + { + name: "no delegations", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + expErr: errors.New("no delegations to undelegate"), + }, + { + name: "no active delegations", + sdb: func() *state.DB { + sdb := makeStateDBForStake(t) + w, _ := sdb.ValidatorWrapper(validatorAddr, false, true) + delegation := staking.NewDelegation(delegatorAddr, big.NewInt(0)) + w.Delegations = append(w.Delegations, delegation) + sdb.UpdateValidatorWrapper(validatorAddr, w) + return sdb + }(), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + expErr: errors.New("no active delegations to undelegate"), + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations) + + if assErr := assertError(err, test.expErr); assErr != nil { + t.Errorf("Test %v: %v", i, assErr) + } + if err != nil || test.expErr != nil { + return + } + + if len(ws) == 0 { + t.Errorf("Test %v: expected at least one wrapper", i) + } + }) + } +} From aa255f7f60397b55ac2a64693b4f02b270dbde0a Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Thu, 1 Jan 2026 00:03:31 +0800 Subject: [PATCH 17/23] Fix UndelegateAll to include delegations created in same block - Update VerifyAndUndelegateAllFromMsg to scan all validators in current state - Ensures delegations created in same block are included when calling UndelegateAll - Add ChainContext parameter to enable validator list scanning - Update all call sites (evm.go, tx_pool.go, tests) to pass chainContext - Prevents missing delegations when user delegates then immediately calls UndelegateAll --- core/evm.go | 2 +- core/staking_verifier.go | 69 ++++++++++++++++++++++++++++++++--- core/staking_verifier_test.go | 31 ++++++++++++++-- core/tx_pool.go | 2 +- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/core/evm.go b/core/evm.go index fdfa1dd50f..80a2d7bb75 100644 --- a/core/evm.go +++ b/core/evm.go @@ -485,7 +485,7 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc } updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( - db, ref.Epoch(), undelegateAll, delegations, + db, ref.Epoch(), undelegateAll, delegations, chain, ) if err != nil { return err diff --git a/core/staking_verifier.go b/core/staking_verifier.go index 1c0f610a01..1b2351854d 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -680,11 +680,12 @@ func VerifyAndBatchUndelegateFromMsg( } // VerifyAndUndelegateAllFromMsg verifies and prepares undelegation of all delegations -// for a delegator. It reads all delegations and creates a batch undelegation. +// for a delegator. It reads all delegations from the current state and creates a batch undelegation. +// This ensures delegations created in the same block are included. // // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndUndelegateAllFromMsg( - stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, + stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, ) ([]*staking.ValidatorWrapper, error) { if stateDB == nil { return nil, errStateDBIsMissing @@ -692,13 +693,12 @@ func VerifyAndUndelegateAllFromMsg( if epoch == nil { return nil, errEpochMissing } - if len(delegations) == 0 { - return nil, errors.New("no delegations to undelegate") - } delegationIndexes := []staking.DelegationIndex{} amounts := []*big.Int{} + processedValidators := map[common.Address]map[uint64]bool{} + // First, process delegations from the provided list (from previous block) for _, delegationIndex := range delegations { if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { continue @@ -706,7 +706,7 @@ func VerifyAndUndelegateAllFromMsg( wrapper, err := stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) if err != nil { - return nil, err + continue } if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { @@ -724,9 +724,66 @@ func VerifyAndUndelegateAllFromMsg( delegationIndexes = append(delegationIndexes, delegationIndex) amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + + // Track processed delegations to avoid duplicates + if processedValidators[delegationIndex.ValidatorAddress] == nil { + processedValidators[delegationIndex.ValidatorAddress] = make(map[uint64]bool) + } + processedValidators[delegationIndex.ValidatorAddress][delegationIndex.Index] = true } + // Then, scan all validators in current state to find any new delegations created in this block + if chainContext != nil { + validatorList, err := chainContext.ReadValidatorList() + if err == nil { + for _, validatorAddr := range validatorList { + if !stateDB.IsValidator(validatorAddr) { + continue + } + + wrapper, err := stateDB.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + continue + } + + // Check all delegations for this delegator + for i := range wrapper.Delegations { + delegation := &wrapper.Delegations[i] + if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + continue + } + + if delegation.Amount.Cmp(common.Big0) <= 0 { + continue + } + + // Skip if already processed + if processedValidators[validatorAddr] != nil && processedValidators[validatorAddr][uint64(i)] { + continue + } + + // Found a new delegation (created in this block) + delegationIndexes = append(delegationIndexes, staking.DelegationIndex{ + ValidatorAddress: validatorAddr, + Index: uint64(i), + BlockNum: big.NewInt(0), + }) + amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + + if processedValidators[validatorAddr] == nil { + processedValidators[validatorAddr] = make(map[uint64]bool) + } + processedValidators[validatorAddr][uint64(i)] = true + } + } + } + } + + // If no delegations found and no chain context to scan, return error if len(delegationIndexes) == 0 { + if chainContext == nil && len(delegations) == 0 { + return nil, errors.New("no delegations to undelegate") + } return nil, errors.New("no active delegations to undelegate") } diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index d9be5f2c8a..3d9e3bbcb6 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -2565,6 +2565,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { epoch *big.Int msg staking.UndelegateAll delegations []staking.DelegationIndex + chain ChainContext expErr error }{ { @@ -2590,6 +2591,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, } }(), + chain: makeFakeChainContextForStake(), }, { name: "nil state db", @@ -2599,6 +2601,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { DelegatorAddress: delegatorAddr, }, delegations: []staking.DelegationIndex{}, + chain: makeFakeChainContextForStake(), expErr: errStateDBIsMissing, }, { @@ -2609,17 +2612,36 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { DelegatorAddress: delegatorAddr, }, delegations: []staking.DelegationIndex{}, + chain: makeFakeChainContextForStake(), expErr: errEpochMissing, }, { - name: "no delegations", - sdb: makeDefaultStateForUndelegate(t), + name: "no delegations in list but found in state scan", + sdb: func() *state.DB { + sdb := makeDefaultStateForUndelegate(t) + return sdb + }(), + epoch: epoch, + msg: staking.UndelegateAll{ + DelegatorAddress: delegatorAddr, + }, + delegations: []staking.DelegationIndex{}, + chain: makeFakeChainContextForStake(), + expErr: nil, + }, + { + name: "no delegations at all", + sdb: func() *state.DB { + sdb := makeStateDBForStake(t) + return sdb + }(), epoch: epoch, msg: staking.UndelegateAll{ DelegatorAddress: delegatorAddr, }, delegations: []staking.DelegationIndex{}, - expErr: errors.New("no delegations to undelegate"), + chain: makeFakeChainContextForStake(), + expErr: errors.New("no active delegations to undelegate"), }, { name: "no active delegations", @@ -2638,13 +2660,14 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { delegations: []staking.DelegationIndex{ {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, }, + chain: makeFakeChainContextForStake(), expErr: errors.New("no active delegations to undelegate"), }, } for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations) + ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) diff --git a/core/tx_pool.go b/core/tx_pool.go index 1053a0d3f7..f5390b7b58 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -986,7 +986,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if err != nil { return err } - _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations) + _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain) return err default: return staking.ErrInvalidStakingKind From 035d00d83582a32169af610f936d2696628388ec Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Wed, 15 Jul 2026 01:22:51 +0800 Subject: [PATCH 18/23] staking v2: batch delegation, tx pool validation, and precompile revert --- accounts/abi/abi.go | 13 +++ core/tx_pool.go | 97 +++++++++++++++++-- core/vm/contracts.go | 22 ++++- core/vm/precompile_revert_test.go | 81 ++++++++++++++++ internal/params/config.go | 2 + rpc/harmony/staking.go | 9 +- staking/precompile_address.go | 6 ++ staking/types/delegation.go | 13 +++ staking/types/delegation_redelegation_test.go | 20 ++++ 9 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 core/vm/precompile_revert_test.go create mode 100644 staking/precompile_address.go create mode 100644 staking/types/delegation_redelegation_test.go diff --git a/accounts/abi/abi.go b/accounts/abi/abi.go index 9950e13725..34895f04e1 100644 --- a/accounts/abi/abi.go +++ b/accounts/abi/abi.go @@ -261,6 +261,19 @@ func (abi *ABI) HasReceive() bool { // revertSelector is a special function selector for revert reason unpacking. var revertSelector = crypto.Keccak256([]byte("Error(string)"))[:4] +// PackRevert ABI-encodes a revert reason using the standard Error(string) selector. +func PackRevert(reason string) ([]byte, error) { + typ, err := NewType("string", "", nil) + if err != nil { + return nil, err + } + packed, err := (Arguments{{Type: typ}}).Pack(reason) + if err != nil { + return nil, err + } + return append(append([]byte(nil), revertSelector...), packed...), nil +} + // UnpackRevert resolves the abi-encoded revert reason. According to the solidity // spec https://solidity.readthedocs.io/en/latest/control-structures.html#revert, // the provided revert reason is abi-encoded as if it were a call to a function diff --git a/core/tx_pool.go b/core/tx_pool.go index f5390b7b58..6b8e577b9b 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -38,6 +38,7 @@ import ( hmyCommon "github.com/harmony-one/harmony/internal/common" "github.com/harmony-one/harmony/internal/utils" "github.com/harmony-one/harmony/shard" + stakingabi "github.com/harmony-one/harmony/staking" staking "github.com/harmony-one/harmony/staking/types" ) @@ -754,13 +755,16 @@ func (pool *TxPool) validateTx(tx types.PoolTransaction, local bool) error { return err } stakingTx, isStakingTx := tx.(*staking.StakingTransaction) + isPrecompileDelegate := pool.isStakingPrecompileDelegate(tx, from) if !isStakingTx || (isStakingTx && stakingTx.StakingType() != staking.DirectiveDelegate) { - if pool.currentState.GetBalance(from).Cmp(cost) < 0 { - return errors.Wrapf( - ErrInsufficientFunds, - "current shard-id: %d", - pool.chain.CurrentBlock().ShardID(), - ) + if !isPrecompileDelegate { + if pool.currentState.GetBalance(from).Cmp(cost) < 0 { + return errors.Wrapf( + ErrInsufficientFunds, + "current shard-id: %d", + pool.chain.CurrentBlock().ShardID(), + ) + } } } intrGas := uint64(0) @@ -779,7 +783,86 @@ func (pool *TxPool) validateTx(tx types.PoolTransaction, local bool) error { if isStakingTx { return pool.validateStakingTx(stakingTx) } - return nil + return pool.validateStakingPrecompileCall(tx, from) +} + +func (pool *TxPool) isStakingPrecompileDelegate(tx types.PoolTransaction, from common.Address) bool { + if pool.chain.CurrentBlock().ShardID() != shard.BeaconChainShardID { + return false + } + if !pool.chainconfig.IsStakingPrecompile(pool.pendingEpoch()) { + return false + } + to := tx.To() + if to == nil || *to != stakingabi.PrecompileAddress { + return false + } + stakeMsg, err := stakingabi.ParseStakeMsg(from, tx.Data()) + if err != nil { + return false + } + _, ok := stakeMsg.(*staking.Delegate) + return ok +} + +func (pool *TxPool) validateStakingPrecompileCall(tx types.PoolTransaction, from common.Address) error { + if pool.chain.CurrentBlock().ShardID() != shard.BeaconChainShardID { + return nil + } + if !pool.chainconfig.IsStakingPrecompile(pool.pendingEpoch()) { + return nil + } + to := tx.To() + if to == nil || *to != stakingabi.PrecompileAddress { + return nil + } + stakeMsg, err := stakingabi.ParseStakeMsg(from, tx.Data()) + if err != nil { + return err + } + + b32, _ := hmyCommon.AddressToBech32(from) + switch msg := stakeMsg.(type) { + case *staking.Delegate: + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(msg.DelegatorAddress) + if err != nil { + return err + } + pendingEpoch := pool.pendingEpoch() + _, delegateAmt, _, err := VerifyAndDelegateFromMsg( + pool.currentState, pendingEpoch, msg, delegations, pool.chainconfig) + if err != nil { + return err + } + gasAmt := new(big.Int).Mul(tx.GasPrice(), new(big.Int).SetUint64(tx.GasLimit())) + totalAmt := new(big.Int).Add(delegateAmt, gasAmt) + if bal := pool.currentState.GetBalance(from); bal.Cmp(totalAmt) < 0 { + return fmt.Errorf("not enough balance for delegation: %v < %v", bal, delegateAmt) + } + return nil + case *staking.Undelegate: + _, err := VerifyAndUndelegateFromMsg(pool.currentState, pool.pendingEpoch(), msg) + return err + case *staking.CollectRewards: + chain, ok := pool.chain.(ChainContext) + if !ok { + utils.Logger().Debug().Msg("Missing chain context in txPool") + return nil + } + delegations, err := chain.ReadDelegationsByDelegator(msg.DelegatorAddress) + if err != nil { + return err + } + _, _, err = VerifyAndCollectRewardsFromDelegation(pool.currentState, delegations) + return err + default: + return errors.WithMessagef(ErrInvalidSender, "staking precompile sender is %s", b32) + } } // validateStakingTx checks the staking message based on the staking directive diff --git a/core/vm/contracts.go b/core/vm/contracts.go index 59ec9549eb..b8aca6b388 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -25,6 +25,7 @@ import ( "math/big" "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/accounts/abi" "github.com/harmony-one/harmony/internal/params" "github.com/ethereum/go-ethereum/common/math" @@ -295,14 +296,31 @@ func RunPrecompiledContract(p WriteCapablePrecompiledContract, evm *EVM, contrac } gasCost, err := p.RequiredGas(evm, contract, input) if err != nil { - return nil, 0, err + return wrapWritePrecompileError(evm, nil, 0, err) } if suppliedGas < gasCost { return nil, 0, ErrOutOfGas } suppliedGas -= gasCost output, err := p.RunWriteCapable(evm, contract, input) - return output, suppliedGas, err + return wrapWritePrecompileError(evm, output, suppliedGas, err) +} + +func wrapWritePrecompileError( + evm *EVM, output []byte, remainingGas uint64, err error, +) ([]byte, uint64, error) { + if err == nil || err == ErrExecutionReverted { + return output, remainingGas, err + } + if evm == nil || evm.ChainConfig() == nil || + !evm.ChainConfig().IsStakingV2(evm.Context.EpochNumber) { + return output, remainingGas, err + } + revertData, packErr := abi.PackRevert(err.Error()) + if packErr != nil { + return output, remainingGas, err + } + return revertData, remainingGas, ErrExecutionReverted } // ECRECOVER implemented as a native contract. diff --git a/core/vm/precompile_revert_test.go b/core/vm/precompile_revert_test.go new file mode 100644 index 0000000000..9c2f8b87ba --- /dev/null +++ b/core/vm/precompile_revert_test.go @@ -0,0 +1,81 @@ +package vm + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/accounts/abi" + "github.com/harmony-one/harmony/internal/params" + "github.com/stretchr/testify/require" +) + +func TestWrapWritePrecompileErrorBeforeFork(t *testing.T) { + cfg := *params.TestChainConfig + cfg.StakingV2Epoch = big.NewInt(100) + evm := NewEVM( + BlockContext{EpochNumber: big.NewInt(1)}, + TxContext{}, + nil, + &cfg, + Config{}, + ) + + origErr := errors.New("insufficient balance to stake") + output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + require.Equal(t, origErr, err) + require.Equal(t, uint64(42_000), gas) + require.Nil(t, output) +} + +func TestWrapWritePrecompileErrorAfterFork(t *testing.T) { + evm := NewEVM( + BlockContext{EpochNumber: big.NewInt(6)}, + TxContext{}, + nil, + params.LocalnetChainConfig, + Config{}, + ) + + origErr := errors.New("insufficient balance to stake") + output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + require.ErrorIs(t, err, ErrExecutionReverted) + require.Equal(t, uint64(42_000), gas) + + reason, unpackErr := abi.UnpackRevert(output) + require.NoError(t, unpackErr) + require.Equal(t, origErr.Error(), reason) +} + +func TestStakingPrecompileAddressMismatchRevertsAfterFork(t *testing.T) { + env := NewEVM(BlockContext{ + CollectRewards: CollectRewardsFn(), + Delegate: DelegateFn(), + Undelegate: UndelegateFn(), + CreateValidator: CreateValidatorFn(), + EditValidator: EditValidatorFn(), + ShardID: 0, + EpochNumber: big.NewInt(6), + CalculateMigrationGas: CalculateMigrationGasFn(), + }, TxContext{}, nil, params.LocalnetChainConfig, Config{}) + + input := []byte{ + 109, 107, 47, 119, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19, 56, + } + contract := NewContract( + AccountRef(common.HexToAddress("0x1337")), + AccountRef(common.HexToAddress("0x1338")), + nil, + 1_000_000, + ) + p := &stakingPrecompile{} + gas, err := p.RequiredGas(env, contract, input) + require.NoError(t, err) + contract.Gas = gas + + _, remainingGas, err := RunPrecompiledContract(p, env, contract, input, gas, false) + require.ErrorIs(t, err, ErrExecutionReverted) + require.NotZero(t, remainingGas) +} diff --git a/internal/params/config.go b/internal/params/config.go index 5c4ee23446..95a39ef71e 100644 --- a/internal/params/config.go +++ b/internal/params/config.go @@ -471,6 +471,7 @@ var ( SlashBallotSignerFixEpoch: big.NewInt(5), VerifyBeaconHeaderSlashEpoch: big.NewInt(5), BloomEpoch: big.NewInt(5), + StakingV2Epoch: big.NewInt(6), } // AllProtocolChanges ... @@ -621,6 +622,7 @@ var ( big.NewInt(1), // SlashBallotSignerFixEpoch big.NewInt(1), // VerifyBeaconHeaderSlashEpoch big.NewInt(1), // BloomEpoch + big.NewInt(0), // StakingV2Epoch } // TestRules ... diff --git a/rpc/harmony/staking.go b/rpc/harmony/staking.go index 1715a486b4..273c71af06 100644 --- a/rpc/harmony/staking.go +++ b/rpc/harmony/staking.go @@ -878,11 +878,10 @@ func (s *PublicStakingService) GetAvailableRedelegationBalance( redelegationTotal := big.NewInt(0) for _, d := range delegations { - for _, u := range d.Undelegations { - if u.Epoch.Cmp(currEpoch) < 1 { // Undelegation.Epoch < currentEpoch - redelegationTotal.Add(redelegationTotal, u.Amount) - } - } + redelegationTotal.Add( + redelegationTotal, + staking.TotalRedelegatableUndelegations(d.Undelegations, currEpoch), + ) } return redelegationTotal, nil } diff --git a/staking/precompile_address.go b/staking/precompile_address.go new file mode 100644 index 0000000000..b805020790 --- /dev/null +++ b/staking/precompile_address.go @@ -0,0 +1,6 @@ +package staking + +import "github.com/ethereum/go-ethereum/common" + +// PrecompileAddress is the EVM staking precompile at 0x…fc (decimal 252). +var PrecompileAddress = common.BytesToAddress([]byte{252}) diff --git a/staking/types/delegation.go b/staking/types/delegation.go index 65bf36cfe5..fef7fdfbe7 100644 --- a/staking/types/delegation.go +++ b/staking/types/delegation.go @@ -152,6 +152,19 @@ func (d *Delegation) Undelegate(epoch *big.Int, amt *big.Int) error { return nil } +// TotalRedelegatableUndelegations returns undelegated tokens eligible for redelegation +// at currEpoch. This matches the redelegation loop in core/staking_verifier.go, which +// only consumes entries with undelegation.Epoch strictly before the current epoch. +func TotalRedelegatableUndelegations(undelegations Undelegations, currEpoch *big.Int) *big.Int { + total := big.NewInt(0) + for _, u := range undelegations { + if u.Epoch.Cmp(currEpoch) < 0 { + total.Add(total, u.Amount) + } + } + return total +} + // TotalInUndelegation - return the total amount of token in undelegation (locking period) func (d *Delegation) TotalInUndelegation() *big.Int { total := big.NewInt(0) diff --git a/staking/types/delegation_redelegation_test.go b/staking/types/delegation_redelegation_test.go new file mode 100644 index 0000000000..b553410690 --- /dev/null +++ b/staking/types/delegation_redelegation_test.go @@ -0,0 +1,20 @@ +package types + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTotalRedelegatableUndelegations(t *testing.T) { + currEpoch := big.NewInt(2965) + delegations := Undelegations{ + {Amount: big.NewInt(100), Epoch: big.NewInt(2964)}, + {Amount: big.NewInt(200), Epoch: big.NewInt(2965)}, + {Amount: big.NewInt(300), Epoch: big.NewInt(2963)}, + } + + total := TotalRedelegatableUndelegations(delegations, currEpoch) + require.Equal(t, int64(400), total.Int64()) +} From 05090a1e9ca701470c4c7431ecd2f6a60f0221d8 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Fri, 17 Jul 2026 06:06:54 +0800 Subject: [PATCH 19/23] staking v2: fix batch composition, epoch gates, and precompile revert scope --- core/evm.go | 51 +++--- core/staking_verifier.go | 90 ++++++++--- core/staking_verifier_test.go | 255 ++++++++++++++++++++++++++---- core/tx_pool.go | 4 +- core/vm/contracts.go | 11 +- core/vm/precompile_revert_test.go | 30 +++- 6 files changed, 361 insertions(+), 80 deletions(-) diff --git a/core/evm.go b/core/evm.go index 80a2d7bb75..8578b90609 100644 --- a/core/evm.go +++ b/core/evm.go @@ -352,20 +352,23 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc db.SubBalance(batchDelegate.DelegatorAddress, balanceToBeDeducted) if rosettaTracer != nil && balanceToBeDeducted.Sign() != 0 { - for _, delegationAction := range batchDelegate.Delegations { - rosettaTracer.AddRosettaLog( - vm.CALL, - &vm.RosettaLogAddressItem{ - Account: &batchDelegate.DelegatorAddress, - }, - &vm.RosettaLogAddressItem{ - Account: &batchDelegate.DelegatorAddress, - SubAccount: &delegationAction.ValidatorAddress, - Metadata: map[string]interface{}{"type": "delegation"}, - }, - delegationAction.Amount, - ) - } + // Attribute liquid funds to each destination proportionally is not + // available without per-action liquid splits; log the aggregate + // liquid deduction once (matches single-Delegate amount semantics + // for the total liquid spent). + dest := batchDelegate.Delegations[0].ValidatorAddress + rosettaTracer.AddRosettaLog( + vm.CALL, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + }, + &vm.RosettaLogAddressItem{ + Account: &batchDelegate.DelegatorAddress, + SubAccount: &dest, + Metadata: map[string]interface{}{"type": "delegation"}, + }, + balanceToBeDeducted, + ) } if len(fromLockedTokens) > 0 { @@ -376,6 +379,12 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc sort.SliceStable(sortedKeys, func(i, j int) bool { return bytes.Compare(sortedKeys[i][:], sortedKeys[j][:]) < 0 }) + // When the batch has a single destination, Rosetta can mirror + // single-Delegate logging (source undelegation -> dest delegation). + var singleDest *common.Address + if len(batchDelegate.Delegations) == 1 { + singleDest = &batchDelegate.Delegations[0].ValidatorAddress + } for _, key := range sortedKeys { redelegatedToken, ok := fromLockedTokens[key] if !ok { @@ -394,6 +403,12 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc if rosettaTracer != nil { fromAccount := common.BytesToAddress(key.Bytes()) + toAccount := fromAccount + metaType := "redelegation" + if singleDest != nil { + toAccount = *singleDest + metaType = "delegation" + } rosettaTracer.AddRosettaLog( vm.CALL, &vm.RosettaLogAddressItem{ @@ -403,8 +418,8 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc }, &vm.RosettaLogAddressItem{ Account: &batchDelegate.DelegatorAddress, - SubAccount: &fromAccount, - Metadata: map[string]interface{}{"type": "delegation"}, + SubAccount: &toAccount, + Metadata: map[string]interface{}{"type": metaType}, }, redelegatedToken, ) @@ -417,7 +432,7 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc func BatchUndelegateFn(ref *block.Header, chain ChainContext) vm.BatchUndelegateFunc { return func(db vm.StateDB, rosettaTracer vm.RosettaTracer, batchUndelegate *stakingTypes.BatchUndelegate) error { - updatedValidatorWrappers, err := VerifyAndBatchUndelegateFromMsg(db, ref.Epoch(), batchUndelegate) + updatedValidatorWrappers, err := VerifyAndBatchUndelegateFromMsg(db, ref.Epoch(), batchUndelegate, chain.Config()) if err != nil { return err } @@ -485,7 +500,7 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc } updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( - db, ref.Epoch(), undelegateAll, delegations, chain, + db, ref.Epoch(), undelegateAll, delegations, chain, chain.Config(), ) if err != nil { return err diff --git a/core/staking_verifier.go b/core/staking_verifier.go index 1b2351854d..819b9b3834 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -277,6 +277,17 @@ var ( // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndDelegateFromMsg( stateDB vm.StateDB, epoch *big.Int, msg *staking.Delegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, +) ([]*staking.ValidatorWrapper, *big.Int, map[common.Address]*big.Int, error) { + return verifyAndDelegateFromMsg(stateDB, epoch, msg, delegations, chainConfig, nil) +} + +// verifyAndDelegateFromMsg is the shared implementation for single and batch +// delegation. When wrapperCache is non-nil, wrappers are reused across calls so +// batch actions compose against the same in-memory state (undelegations, +// amounts) without mutating stateDB. +func verifyAndDelegateFromMsg( + stateDB vm.StateDB, epoch *big.Int, msg *staking.Delegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, + wrapperCache map[common.Address]*staking.ValidatorWrapper, ) ([]*staking.ValidatorWrapper, *big.Int, map[common.Address]*big.Int, error) { if stateDB == nil { return nil, nil, nil, errStateDBIsMissing @@ -297,6 +308,22 @@ func VerifyAndDelegateFromMsg( } } + getWrapper := func(addr common.Address) (*staking.ValidatorWrapper, error) { + if wrapperCache != nil { + if cached, ok := wrapperCache[addr]; ok { + return cached, nil + } + } + wrapper, err := stateDB.ValidatorWrapper(addr, false, true) + if err != nil { + return nil, err + } + if wrapperCache != nil { + wrapperCache[addr] = wrapper + } + return wrapper, nil + } + updatedValidatorWrappers := []*staking.ValidatorWrapper{} delegateBalance := big.NewInt(0).Set(msg.Amount) fromLockedTokens := map[common.Address]*big.Int{} @@ -306,8 +333,7 @@ func VerifyAndDelegateFromMsg( // Check if we can use tokens in undelegation to delegate (redelegate) for i := range delegations { delegationIndex := &delegations[i] - // request a copy, and since delegations will be changed, copy them too - wrapper, err := stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, true) + wrapper, err := getWrapper(delegationIndex.ValidatorAddress) if err != nil { return nil, nil, nil, err } @@ -407,8 +433,7 @@ func VerifyAndDelegateFromMsg( if delegateeWrapper == nil { var err error - // request a copy, and since delegations will be changed, copy them too - delegateeWrapper, err = stateDB.ValidatorWrapper(msg.ValidatorAddress, false, true) + delegateeWrapper, err = getWrapper(msg.ValidatorAddress) if err != nil { return nil, nil, nil, err } @@ -533,10 +558,9 @@ func VerifyAndBatchDelegateFromMsg( return nil, nil, nil, errors.New("batch delegation must contain at least one delegation") } - allUpdatedWrappers := []*staking.ValidatorWrapper{} + wrapperCache := map[common.Address]*staking.ValidatorWrapper{} totalBalanceToDeduct := big.NewInt(0) allFromLockedTokens := map[common.Address]*big.Int{} - wrapperMap := map[common.Address]*staking.ValidatorWrapper{} for _, delegationAction := range msg.Delegations { if !stateDB.IsValidator(delegationAction.ValidatorAddress) { @@ -561,24 +585,13 @@ func VerifyAndBatchDelegateFromMsg( Amount: delegationAction.Amount, } - updatedWrappers, balanceToDeduct, fromLockedTokens, err := VerifyAndDelegateFromMsg( - stateDB, epoch, delegateMsg, delegations, chainConfig, + _, balanceToDeduct, fromLockedTokens, err := verifyAndDelegateFromMsg( + stateDB, epoch, delegateMsg, delegations, chainConfig, wrapperCache, ) if err != nil { return nil, nil, nil, err } - for _, wrapper := range updatedWrappers { - if existingWrapper, exists := wrapperMap[wrapper.Address]; exists { - if existingWrapper != wrapper { - return nil, nil, nil, errors.New("duplicate validator wrapper in batch delegation") - } - } else { - wrapperMap[wrapper.Address] = wrapper - allUpdatedWrappers = append(allUpdatedWrappers, wrapper) - } - } - totalBalanceToDeduct.Add(totalBalanceToDeduct, balanceToDeduct) for validatorAddr, amount := range fromLockedTokens { @@ -599,6 +612,22 @@ func VerifyAndBatchDelegateFromMsg( } } + // Preserve stable insertion order from first touch in the cache. + allUpdatedWrappers := make([]*staking.ValidatorWrapper, 0, len(wrapperCache)) + seen := map[common.Address]bool{} + for _, delegationAction := range msg.Delegations { + if w, ok := wrapperCache[delegationAction.ValidatorAddress]; ok && !seen[w.Address] { + allUpdatedWrappers = append(allUpdatedWrappers, w) + seen[w.Address] = true + } + } + for addr, w := range wrapperCache { + if !seen[addr] { + allUpdatedWrappers = append(allUpdatedWrappers, w) + seen[addr] = true + } + } + return allUpdatedWrappers, totalBalanceToDeduct, allFromLockedTokens, nil } @@ -607,7 +636,7 @@ func VerifyAndBatchDelegateFromMsg( // // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndBatchUndelegateFromMsg( - stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchUndelegate, + stateDB vm.StateDB, epoch *big.Int, msg *staking.BatchUndelegate, chainConfig *params.ChainConfig, ) ([]*staking.ValidatorWrapper, error) { if stateDB == nil { return nil, errStateDBIsMissing @@ -615,6 +644,12 @@ func VerifyAndBatchUndelegateFromMsg( if epoch == nil { return nil, errEpochMissing } + if chainConfig == nil { + return nil, errors.New("chain config is required") + } + if !chainConfig.IsStakingV2(epoch) { + return nil, errors.New("batch undelegation is only available in StakingV2 epoch") + } if len(msg.DelegationIndexes) == 0 { return nil, errors.New("batch undelegation must contain at least one delegation index") } @@ -685,7 +720,7 @@ func VerifyAndBatchUndelegateFromMsg( // // Note that this function never updates the stateDB, it only reads from stateDB. func VerifyAndUndelegateAllFromMsg( - stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, + stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, chainConfig *params.ChainConfig, ) ([]*staking.ValidatorWrapper, error) { if stateDB == nil { return nil, errStateDBIsMissing @@ -693,6 +728,17 @@ func VerifyAndUndelegateAllFromMsg( if epoch == nil { return nil, errEpochMissing } + if chainConfig == nil { + if chainContext != nil { + chainConfig = chainContext.Config() + } + } + if chainConfig == nil { + return nil, errors.New("chain config is required") + } + if !chainConfig.IsStakingV2(epoch) { + return nil, errors.New("undelegate all is only available in StakingV2 epoch") + } delegationIndexes := []staking.DelegationIndex{} amounts := []*big.Int{} @@ -793,7 +839,7 @@ func VerifyAndUndelegateAllFromMsg( Amounts: amounts, } - return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg) + return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg, chainConfig) } // VerifyAndMigrateFromMsg verifies and transfers all delegations of diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 3d9e3bbcb6..0f3f52fc53 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -1751,6 +1751,8 @@ func (chain *fakeChainContext) Config() *params.ChainConfig { config := ¶ms.ChainConfig{} config.MinCommissionRateEpoch = big.NewInt(0) config.MinCommissionPromoPeriod = big.NewInt(10) + config.StakingV2Epoch = big.NewInt(0) + config.RedelegationEpoch = big.NewInt(0) return config } @@ -2399,13 +2401,19 @@ func TestVerifyAndBatchDelegateFromMsg(t *testing.T) { func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { epoch := big.NewInt(defaultEpoch) + stakingV2Config := func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + return config + } tests := []struct { - name string - sdb vm.StateDB - epoch *big.Int - msg staking.BatchUndelegate - expErr error + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchUndelegate + chainConfig *params.ChainConfig + expErr error }{ { name: "successful batch undelegate from two validators", @@ -2420,7 +2428,8 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { sdb.IntermediateRoot(true) return sdb }(), - epoch: epoch, + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2434,9 +2443,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { }, }, { - name: "nil state db", - sdb: nil, - epoch: epoch, + name: "nil state db", + sdb: nil, + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2447,9 +2457,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errStateDBIsMissing, }, { - name: "nil epoch", - sdb: makeDefaultStateForUndelegate(t), - epoch: nil, + name: "nil epoch", + sdb: makeDefaultStateForUndelegate(t), + epoch: nil, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2460,9 +2471,28 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errEpochMissing, }, { - name: "empty delegation indexes", + name: "not StakingV2 epoch", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, + chainConfig: func() *params.ChainConfig { + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = big.NewInt(10000000) + return config + }(), + msg: staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + DelegationIndexes: []staking.DelegationIndex{ + {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + }, + Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + }, + expErr: errors.New("batch undelegation is only available in StakingV2 epoch"), + }, + { + name: "empty delegation indexes", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{}, @@ -2471,9 +2501,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errors.New("batch undelegation must contain at least one delegation index"), }, { - name: "mismatched lengths", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "mismatched lengths", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2485,9 +2516,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errors.New("delegation indexes and amounts must have the same length"), }, { - name: "invalid validator", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "invalid validator", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2498,9 +2530,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errValidatorNotExist, }, { - name: "negative amount", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "negative amount", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2511,9 +2544,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errNegativeAmount, }, { - name: "delegation index out of bound", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "delegation index out of bound", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, DelegationIndexes: []staking.DelegationIndex{ @@ -2524,9 +2558,10 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { expErr: errors.New("Delegation index out of bound"), }, { - name: "delegator address mismatch", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, + name: "delegator address mismatch", + sdb: makeDefaultStateForUndelegate(t), + epoch: epoch, + chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: makeTestAddr("wrong delegator"), DelegationIndexes: []staking.DelegationIndex{ @@ -2540,7 +2575,7 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndBatchUndelegateFromMsg(test.sdb, test.epoch, &test.msg) + ws, err := VerifyAndBatchUndelegateFromMsg(test.sdb, test.epoch, &test.msg, test.chainConfig) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) @@ -2667,7 +2702,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain) + ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain, nil) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) @@ -2682,3 +2717,163 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { }) } } + +func TestBatchDelegateRedelegationComposition(t *testing.T) { + epoch := big.NewInt(10) + oldEpoch := big.NewInt(5) + + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: new(big.Int).Set(fifteenKOnes), epoch: oldEpoch}, + }) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{{ + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), + BlockNum: big.NewInt(100), + }} + + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.RedelegationEpoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + // Two destinations share the same locked-token source. Composition must + // consume undelegations sequentially (not double-count from fresh copies). + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr2, Amount: new(big.Int).Set(tenKOnes)}, + }, + } + + ws, balance, fromLocked, err := VerifyAndBatchDelegateFromMsg( + sdb, epoch, &msg, delegationIndex, config, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance.Sign() != 0 { + t.Fatalf("expected fully locked-funded batch, got liquid deduct %v", balance) + } + locked, ok := fromLocked[validatorAddr] + if !ok || locked.Cmp(fifteenKOnes) != 0 { + t.Fatalf("expected 15k locked from source validator, got %v (ok=%v)", locked, ok) + } + + var sourceUndelegations staking.Undelegations + for _, wrapper := range ws { + if wrapper.Address == validatorAddr { + for _, del := range wrapper.Delegations { + if del.DelegatorAddress == delegatorAddr { + sourceUndelegations = del.Undelegations + } + } + } + } + if len(sourceUndelegations) != 0 { + t.Fatalf("expected all locked tokens consumed, remaining %+v", sourceUndelegations) + } +} + +func TestBatchDelegateSameValidatorTwice(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + } + + ws, balance, _, err := VerifyAndBatchDelegateFromMsg( + makeStateDBForStake(t), epoch, &msg, nil, config, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance.Cmp(fifteenKOnes) != 0 { + t.Fatalf("expected liquid deduct 15k, got %v", balance) + } + if len(ws) != 1 { + t.Fatalf("expected single wrapper, got %d", len(ws)) + } + found := false + for _, del := range ws[0].Delegations { + if del.DelegatorAddress == delegatorAddr { + found = true + if del.Amount.Cmp(fifteenKOnes) != 0 { + t.Fatalf("expected combined delegation 15k, got %v", del.Amount) + } + } + } + if !found { + t.Fatal("missing combined delegation") + } +} + +func TestRedelegationSkipsSameEpochUndelegation(t *testing.T) { + epoch := big.NewInt(10) + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: new(big.Int).Set(tenKOnes), epoch: epoch}, // same epoch: not eligible + }) + // Leave almost no liquid balance so same-epoch locked funds cannot silently fund the stake. + sdb.SetBalance(delegatorAddr, big.NewInt(0)) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{{ + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), + BlockNum: big.NewInt(100), + }} + + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.RedelegationEpoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + msg := staking.Delegate{ + DelegatorAddress: delegatorAddr, + ValidatorAddress: validatorAddr, + Amount: new(big.Int).Set(fiveKOnes), + } + _, _, _, err = VerifyAndDelegateFromMsg(sdb, epoch, &msg, delegationIndex, config) + if assErr := assertError(err, errInsufficientBalanceForStake); assErr != nil { + t.Fatal(assErr) + } +} + +func TestUndelegateAllRequiresStakingV2(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = big.NewInt(10000000) + + _, err := VerifyAndUndelegateAllFromMsg( + makeDefaultStateForUndelegate(t), + epoch, + &staking.UndelegateAll{DelegatorAddress: delegatorAddr}, + []staking.DelegationIndex{{ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}}, + makeFakeChainContextForStake(), + config, + ) + if assErr := assertError(err, errors.New("undelegate all is only available in StakingV2 epoch")); assErr != nil { + t.Fatal(assErr) + } +} diff --git a/core/tx_pool.go b/core/tx_pool.go index 6b8e577b9b..2711a6e06b 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -1042,7 +1042,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if from != stkMsg.DelegatorAddress { return errors.WithMessagef(ErrInvalidSender, "staking transaction sender is %s", b32) } - _, err = VerifyAndBatchUndelegateFromMsg(pool.currentState, pendingEpoch, stkMsg) + _, err = VerifyAndBatchUndelegateFromMsg(pool.currentState, pendingEpoch, stkMsg, pool.chainconfig) return err case staking.DirectiveUndelegateAll: pendingEpoch := pool.pendingEpoch() @@ -1069,7 +1069,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if err != nil { return err } - _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain) + _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain, pool.chainconfig) return err default: return staking.ErrInvalidStakingKind diff --git a/core/vm/contracts.go b/core/vm/contracts.go index b8aca6b388..94fef3b22b 100644 --- a/core/vm/contracts.go +++ b/core/vm/contracts.go @@ -296,22 +296,27 @@ func RunPrecompiledContract(p WriteCapablePrecompiledContract, evm *EVM, contrac } gasCost, err := p.RequiredGas(evm, contract, input) if err != nil { - return wrapWritePrecompileError(evm, nil, 0, err) + return wrapWritePrecompileError(evm, p, nil, 0, err) } if suppliedGas < gasCost { return nil, 0, ErrOutOfGas } suppliedGas -= gasCost output, err := p.RunWriteCapable(evm, contract, input) - return wrapWritePrecompileError(evm, output, suppliedGas, err) + return wrapWritePrecompileError(evm, p, output, suppliedGas, err) } func wrapWritePrecompileError( - evm *EVM, output []byte, remainingGas uint64, err error, + evm *EVM, p WriteCapablePrecompiledContract, output []byte, remainingGas uint64, err error, ) ([]byte, uint64, error) { if err == nil || err == ErrExecutionReverted { return output, remainingGas, err } + // Only the staking precompile switches to ABI Error(string) reverts after StakingV2. + // Other write-capable precompiles keep their prior exceptional-error behavior. + if _, ok := p.(*stakingPrecompile); !ok { + return output, remainingGas, err + } if evm == nil || evm.ChainConfig() == nil || !evm.ChainConfig().IsStakingV2(evm.Context.EpochNumber) { return output, remainingGas, err diff --git a/core/vm/precompile_revert_test.go b/core/vm/precompile_revert_test.go index 9c2f8b87ba..460fa53920 100644 --- a/core/vm/precompile_revert_test.go +++ b/core/vm/precompile_revert_test.go @@ -23,7 +23,7 @@ func TestWrapWritePrecompileErrorBeforeFork(t *testing.T) { ) origErr := errors.New("insufficient balance to stake") - output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + output, gas, err := wrapWritePrecompileError(evm, &stakingPrecompile{}, nil, 42_000, origErr) require.Equal(t, origErr, err) require.Equal(t, uint64(42_000), gas) require.Nil(t, output) @@ -39,7 +39,7 @@ func TestWrapWritePrecompileErrorAfterFork(t *testing.T) { ) origErr := errors.New("insufficient balance to stake") - output, gas, err := wrapWritePrecompileError(evm, nil, 42_000, origErr) + output, gas, err := wrapWritePrecompileError(evm, &stakingPrecompile{}, nil, 42_000, origErr) require.ErrorIs(t, err, ErrExecutionReverted) require.Equal(t, uint64(42_000), gas) @@ -48,6 +48,22 @@ func TestWrapWritePrecompileErrorAfterFork(t *testing.T) { require.Equal(t, origErr.Error(), reason) } +func TestWrapWritePrecompileErrorSkipsNonStaking(t *testing.T) { + evm := NewEVM( + BlockContext{EpochNumber: big.NewInt(6)}, + TxContext{}, + nil, + params.LocalnetChainConfig, + Config{}, + ) + + origErr := errors.New("cross shard transfer failed") + output, gas, err := wrapWritePrecompileError(evm, &crossShardXferPrecompile{}, nil, 42_000, origErr) + require.Equal(t, origErr, err) + require.Equal(t, uint64(42_000), gas) + require.Nil(t, output) +} + func TestStakingPrecompileAddressMismatchRevertsAfterFork(t *testing.T) { env := NewEVM(BlockContext{ CollectRewards: CollectRewardsFn(), @@ -73,9 +89,13 @@ func TestStakingPrecompileAddressMismatchRevertsAfterFork(t *testing.T) { p := &stakingPrecompile{} gas, err := p.RequiredGas(env, contract, input) require.NoError(t, err) - contract.Gas = gas + contract.Gas = gas + 1_000 - _, remainingGas, err := RunPrecompiledContract(p, env, contract, input, gas, false) + output, remainingGas, err := RunPrecompiledContract(p, env, contract, input, gas+1_000, false) require.ErrorIs(t, err, ErrExecutionReverted) - require.NotZero(t, remainingGas) + require.Equal(t, uint64(1_000), remainingGas) + + reason, unpackErr := abi.UnpackRevert(output) + require.NoError(t, unpackErr) + require.NotEmpty(t, reason) } From 36fad86fb12cafb4ff739c26f78fa035226f6b85 Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Tue, 21 Jul 2026 19:04:41 +0800 Subject: [PATCH 20/23] fix Staking V2 malformed staking precompile input test --- core/evm_test.go | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/core/evm_test.go b/core/evm_test.go index ae63347f5f..adc34f0037 100644 --- a/core/evm_test.go +++ b/core/evm_test.go @@ -11,6 +11,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethdb" + "github.com/harmony-one/harmony/accounts/abi" "github.com/harmony-one/harmony/block" blockfactory "github.com/harmony-one/harmony/block/factory" "github.com/harmony-one/harmony/common/denominations" @@ -444,15 +445,25 @@ func TestWriteCapablePrecompilesIntegration(t *testing.T) { evm := vm.NewEVM(ctx, NewEVMTxContext(msg), db, params.TestChainConfig, vm.Config{}) // interpreter := vm.NewEVMInterpreter(evm, vm.Config{}) address := common.BytesToAddress([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252}) - // caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) - _, _, err := evm.Call(vm.AccountRef(common.Address{}), address, - []byte{109, 107, 47, 119, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19}, - math.MaxUint64, new(big.Int)) - expectedError := errors.New("abi: cannot marshal in to go type: length insufficient 31 require 32") - if err != nil { - if err.Error() != expectedError.Error() { - t.Error(fmt.Sprintf("Got error %v in evm.Call but expected %v", err, expectedError)) + malformedInput := []byte{109, 107, 47, 119, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 19} + expectedABIError := "abi: cannot marshal in to go type: length insufficient 31 require 32" + ret, _, err := evm.Call(vm.AccountRef(common.Address{}), address, malformedInput, math.MaxUint64, new(big.Int)) + if err == nil { + t.Fatal("expected error from malformed staking precompile input") + } + if params.TestChainConfig.IsStakingV2(header.Epoch()) { + if !errors.Is(err, vm.ErrExecutionReverted) { + t.Errorf("Got error %v in evm.Call but expected %v", err, vm.ErrExecutionReverted) + } + reason, unpackErr := abi.UnpackRevert(ret) + if unpackErr != nil { + t.Fatalf("failed to unpack revert data: %v", unpackErr) + } + if reason != expectedABIError { + t.Errorf("Got revert reason %q but expected %q", reason, expectedABIError) } + } else if err.Error() != expectedABIError { + t.Errorf("Got error %v in evm.Call but expected %v", err, expectedABIError) } // now add a validator, and send its address as caller From 203ffb65a35c20180482109573775c75209b9e9e Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Tue, 21 Jul 2026 19:53:59 +0800 Subject: [PATCH 21/23] fix staking undelegation same-epoch --- core/staking_verifier.go | 14 ++++++++------ staking/types/delegation.go | 23 +++++++++-------------- staking/types/delegation_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/core/staking_verifier.go b/core/staking_verifier.go index 819b9b3834..b03ef89ad6 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -906,15 +906,17 @@ func VerifyAndMigrateFromMsg( totalAmount = delegation.Amount.Add(delegation.Amount, delegationAmountToMigrate) // and the undelegations for _, undelegationToMigrate := range undelegationsToMigrate { - exist := false - for _, entry := range delegation.Undelegations { - if entry.Epoch.Cmp(undelegationToMigrate.Epoch) == 0 { - exist = true - entry.Amount.Add(entry.Amount, undelegationToMigrate.Amount) + merged := false + for i := range delegation.Undelegations { + if delegation.Undelegations[i].Epoch.Cmp(undelegationToMigrate.Epoch) == 0 { + delegation.Undelegations[i].Amount.Add( + delegation.Undelegations[i].Amount, undelegationToMigrate.Amount, + ) + merged = true break } } - if !exist { + if !merged { delegation.Undelegations = append(delegation.Undelegations, undelegationToMigrate) } diff --git a/staking/types/delegation.go b/staking/types/delegation.go index fef7fdfbe7..8f7cbf536e 100644 --- a/staking/types/delegation.go +++ b/staking/types/delegation.go @@ -129,25 +129,20 @@ func (d *Delegation) Undelegate(epoch *big.Int, amt *big.Int) error { } d.Amount.Sub(d.Amount, amt) - exist := false - for _, entry := range d.Undelegations { - if entry.Epoch.Cmp(epoch) == 0 { - exist = true - entry.Amount.Add(entry.Amount, amt) + for i := range d.Undelegations { + if d.Undelegations[i].Epoch.Cmp(epoch) == 0 { + d.Undelegations[i].Amount.Add(d.Undelegations[i].Amount, amt) return nil } } - if !exist { - item := Undelegation{amt, epoch} - d.Undelegations = append(d.Undelegations, item) + d.Undelegations = append(d.Undelegations, Undelegation{amt, epoch}) - // Always sort the undelegate by epoch in increasing order - sort.SliceStable( - d.Undelegations, - func(i, j int) bool { return d.Undelegations[i].Epoch.Cmp(d.Undelegations[j].Epoch) < 0 }, - ) - } + // Always sort the undelegate by epoch in increasing order + sort.SliceStable( + d.Undelegations, + func(i, j int) bool { return d.Undelegations[i].Epoch.Cmp(d.Undelegations[j].Epoch) < 0 }, + ) return nil } diff --git a/staking/types/delegation_test.go b/staking/types/delegation_test.go index ff0c25c02b..7031f28d02 100644 --- a/staking/types/delegation_test.go +++ b/staking/types/delegation_test.go @@ -16,6 +16,36 @@ var ( delegation = NewDelegation(delegatorAddr, delegationAmt) ) +func TestUndelegateSameEpoch(t *testing.T) { + d := NewDelegation(delegatorAddr, big.NewInt(100000)) + epoch := big.NewInt(10) + amount1 := big.NewInt(1000) + amount2 := big.NewInt(2000) + + if err := d.Undelegate(epoch, amount1); err != nil { + t.Fatalf("first undelegate failed: %v", err) + } + if err := d.Undelegate(epoch, amount2); err != nil { + t.Fatalf("second undelegate failed: %v", err) + } + + if len(d.Undelegations) != 1 { + t.Fatalf("expected one undelegation entry, got %d", len(d.Undelegations)) + } + expectedUndelegated := big.NewInt(3000) + if d.Undelegations[0].Amount.Cmp(expectedUndelegated) != 0 { + t.Errorf("same-epoch merge: undelegation amount = %s, want %s", + d.Undelegations[0].Amount, expectedUndelegated) + } + if d.Undelegations[0].Epoch.Cmp(epoch) != 0 { + t.Errorf("undelegation epoch = %s, want %s", d.Undelegations[0].Epoch, epoch) + } + expectedDelegated := big.NewInt(97000) + if d.Amount.Cmp(expectedDelegated) != 0 { + t.Errorf("delegated amount = %s, want %s", d.Amount, expectedDelegated) + } +} + func TestUndelegate(t *testing.T) { epoch1 := big.NewInt(10) amount1 := big.NewInt(1000) From 387fe167acc693fd77854e403fa54c9484f75d2f Mon Sep 17 00:00:00 2001 From: GheisMohammadi Date: Thu, 23 Jul 2026 00:42:14 +0800 Subject: [PATCH 22/23] staking v2: reshape BatchUndelegate API and add batch gas limits --- core/evm.go | 43 +---- core/staking_verifier.go | 186 +++++++++--------- core/staking_verifier_test.go | 297 +++++++++++++++++++++++------ core/state_transition.go | 24 +++ core/tx_pool.go | 12 +- internal/params/protocol_params.go | 4 + staking/types/gas.go | 62 ++++++ staking/types/gas_test.go | 80 ++++++++ staking/types/messages.go | 60 +++--- 9 files changed, 551 insertions(+), 217 deletions(-) create mode 100644 staking/types/gas.go create mode 100644 staking/types/gas_test.go diff --git a/core/evm.go b/core/evm.go index 8578b90609..3b75ce5bdd 100644 --- a/core/evm.go +++ b/core/evm.go @@ -352,10 +352,6 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc db.SubBalance(batchDelegate.DelegatorAddress, balanceToBeDeducted) if rosettaTracer != nil && balanceToBeDeducted.Sign() != 0 { - // Attribute liquid funds to each destination proportionally is not - // available without per-action liquid splits; log the aggregate - // liquid deduction once (matches single-Delegate amount semantics - // for the total liquid spent). dest := batchDelegate.Delegations[0].ValidatorAddress rosettaTracer.AddRosettaLog( vm.CALL, @@ -379,8 +375,6 @@ func BatchDelegateFn(ref *block.Header, chain ChainContext) vm.BatchDelegateFunc sort.SliceStable(sortedKeys, func(i, j int) bool { return bytes.Compare(sortedKeys[i][:], sortedKeys[j][:]) < 0 }) - // When the batch has a single destination, Rosetta can mirror - // single-Delegate logging (source undelegation -> dest delegation). var singleDest *common.Address if len(batchDelegate.Delegations) == 1 { singleDest = &batchDelegate.Delegations[0].ValidatorAddress @@ -444,18 +438,19 @@ func BatchUndelegateFn(ref *block.Header, chain ChainContext) vm.BatchUndelegate } if rosettaTracer != nil { - for i, delegationIndex := range batchUndelegate.DelegationIndexes { - amount := batchUndelegate.Amounts[i] + for _, action := range batchUndelegate.Undelegations { + amount := action.Amount + validatorAddr := action.ValidatorAddress rosettaTracer.AddRosettaLog( vm.CALL, &vm.RosettaLogAddressItem{ Account: &batchUndelegate.DelegatorAddress, - SubAccount: &delegationIndex.ValidatorAddress, + SubAccount: &validatorAddr, Metadata: map[string]interface{}{"type": "delegation"}, }, &vm.RosettaLogAddressItem{ Account: &batchUndelegate.DelegatorAddress, - SubAccount: &delegationIndex.ValidatorAddress, + SubAccount: &validatorAddr, Metadata: map[string]interface{}{"type": "undelegation"}, }, amount, @@ -477,29 +472,7 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc return err } - // Track original amounts before undelegation for rosetta logging - originalAmounts := map[common.Address]*big.Int{} - for _, delegationIndex := range delegations { - if !db.IsValidator(delegationIndex.ValidatorAddress) { - continue - } - wrapper, err := db.ValidatorWrapper(delegationIndex.ValidatorAddress, false, false) - if err != nil { - continue - } - if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { - continue - } - delegation := &wrapper.Delegations[delegationIndex.Index] - if !bytes.Equal(delegation.DelegatorAddress.Bytes(), undelegateAll.DelegatorAddress.Bytes()) { - continue - } - if delegation.Amount.Cmp(common.Big0) > 0 { - originalAmounts[delegationIndex.ValidatorAddress] = new(big.Int).Set(delegation.Amount) - } - } - - updatedValidatorWrappers, err := VerifyAndUndelegateAllFromMsg( + updatedValidatorWrappers, actions, err := VerifyAndUndelegateAllFromMsg( db, ref.Epoch(), undelegateAll, delegations, chain, chain.Config(), ) if err != nil { @@ -512,7 +485,9 @@ func UndelegateAllFn(ref *block.Header, chain ChainContext) vm.UndelegateAllFunc } if rosettaTracer != nil { - for validatorAddr, amount := range originalAmounts { + for _, action := range actions { + validatorAddr := action.ValidatorAddress + amount := action.Amount rosettaTracer.AddRosettaLog( vm.CALL, &vm.RosettaLogAddressItem{ diff --git a/core/staking_verifier.go b/core/staking_verifier.go index b03ef89ad6..92c11a380d 100644 --- a/core/staking_verifier.go +++ b/core/staking_verifier.go @@ -281,10 +281,8 @@ func VerifyAndDelegateFromMsg( return verifyAndDelegateFromMsg(stateDB, epoch, msg, delegations, chainConfig, nil) } -// verifyAndDelegateFromMsg is the shared implementation for single and batch -// delegation. When wrapperCache is non-nil, wrappers are reused across calls so -// batch actions compose against the same in-memory state (undelegations, -// amounts) without mutating stateDB. +// verifyAndDelegateFromMsg implements single and batch delegation. +// When wrapperCache is non-nil, wrappers are reused across calls. func verifyAndDelegateFromMsg( stateDB vm.StateDB, epoch *big.Int, msg *staking.Delegate, delegations []staking.DelegationIndex, chainConfig *params.ChainConfig, wrapperCache map[common.Address]*staking.ValidatorWrapper, @@ -359,22 +357,17 @@ func verifyAndDelegateFromMsg( isStakingV2 := chainConfig.IsStakingV2(epoch) if isStakingV2 { - // Staking V2: Properly handle undelegation consumption with explicit entry removal newUndelegations := []staking.Undelegation{} for curIndex < len(delegation.Undelegations) { entry := &delegation.Undelegations[curIndex] if entry.Epoch.Cmp(epoch) >= 0 { - // Keep all remaining entries (not yet eligible for redelegation) newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) break } if entry.Amount.Cmp(delegateBalance) <= 0 { - // Fully consume this entry delegateBalance.Sub(delegateBalance, entry.Amount) - // Don't add to newUndelegations (fully consumed) } else { - // Partially consume this entry remainingAmount := big.NewInt(0).Sub(entry.Amount, delegateBalance) newUndelegations = append(newUndelegations, staking.Undelegation{ Amount: remainingAmount, @@ -382,7 +375,6 @@ func verifyAndDelegateFromMsg( }) delegateBalance = big.NewInt(0) curIndex++ - // Keep all remaining entries if curIndex < len(delegation.Undelegations) { newUndelegations = append(newUndelegations, delegation.Undelegations[curIndex:]...) } @@ -390,12 +382,10 @@ func verifyAndDelegateFromMsg( } curIndex++ } - // Only update undelegations if something was consumed if startBalance.Cmp(delegateBalance) > 0 { delegation.Undelegations = newUndelegations } } else { - // Original logic (for backward compatibility) for ; curIndex < len(delegation.Undelegations); curIndex++ { if delegation.Undelegations[curIndex].Epoch.Cmp(epoch) >= 0 { break @@ -413,9 +403,7 @@ func verifyAndDelegateFromMsg( } if startBalance.Cmp(delegateBalance) > 0 { - // Used undelegated token for redelegation if !isStakingV2 { - // Original logic: slice undelegations array delegation.Undelegations = delegation.Undelegations[curIndex:] } if err := wrapper.SanityCheck(); err != nil { @@ -475,9 +463,8 @@ func verifyAndDelegateFromMsg( return updatedValidatorWrappers, big.NewInt(0), fromLockedTokens, nil } - // Still need to deduct tokens from balance for delegation - // Check if there is enough liquid token to delegate - if !CanTransfer(stateDB, msg.DelegatorAddress, delegateBalance) { + // Liquid balance is checked only when wrapperCache is nil. + if wrapperCache == nil && !CanTransfer(stateDB, msg.DelegatorAddress, delegateBalance) { return nil, nil, nil, errors.Wrapf( errInsufficientBalanceForStake, "totalRedelegatable: %v, balance: %v; trying to stake %v", big.NewInt(0).Sub(msg.Amount, delegateBalance), stateDB.GetBalance(msg.DelegatorAddress), msg.Amount) @@ -557,6 +544,9 @@ func VerifyAndBatchDelegateFromMsg( if len(msg.Delegations) == 0 { return nil, nil, nil, errors.New("batch delegation must contain at least one delegation") } + if len(msg.Delegations) > staking.MaxBatchStakingActions { + return nil, nil, nil, staking.ErrBatchTooLarge + } wrapperCache := map[common.Address]*staking.ValidatorWrapper{} totalBalanceToDeduct := big.NewInt(0) @@ -612,23 +602,37 @@ func VerifyAndBatchDelegateFromMsg( } } - // Preserve stable insertion order from first touch in the cache. + return sortedWrappersFromCache(wrapperCache, msg.Delegations), totalBalanceToDeduct, allFromLockedTokens, nil +} + +// sortedWrappersFromCache returns wrappers for action destinations in order, +// then any remaining cached addresses sorted by address. +func sortedWrappersFromCache( + wrapperCache map[common.Address]*staking.ValidatorWrapper, + actions []staking.DelegationAction, +) []*staking.ValidatorWrapper { allUpdatedWrappers := make([]*staking.ValidatorWrapper, 0, len(wrapperCache)) seen := map[common.Address]bool{} - for _, delegationAction := range msg.Delegations { - if w, ok := wrapperCache[delegationAction.ValidatorAddress]; ok && !seen[w.Address] { + for _, action := range actions { + addr := action.ValidatorAddress + if w, ok := wrapperCache[addr]; ok && !seen[addr] { allUpdatedWrappers = append(allUpdatedWrappers, w) - seen[w.Address] = true + seen[addr] = true } } - for addr, w := range wrapperCache { + rest := make([]common.Address, 0, len(wrapperCache)) + for addr := range wrapperCache { if !seen[addr] { - allUpdatedWrappers = append(allUpdatedWrappers, w) - seen[addr] = true + rest = append(rest, addr) } } - - return allUpdatedWrappers, totalBalanceToDeduct, allFromLockedTokens, nil + sort.Slice(rest, func(i, j int) bool { + return bytes.Compare(rest[i][:], rest[j][:]) < 0 + }) + for _, addr := range rest { + allUpdatedWrappers = append(allUpdatedWrappers, wrapperCache[addr]) + } + return allUpdatedWrappers } // VerifyAndBatchUndelegateFromMsg verifies batch undelegation message using the stateDB @@ -650,57 +654,64 @@ func VerifyAndBatchUndelegateFromMsg( if !chainConfig.IsStakingV2(epoch) { return nil, errors.New("batch undelegation is only available in StakingV2 epoch") } - if len(msg.DelegationIndexes) == 0 { - return nil, errors.New("batch undelegation must contain at least one delegation index") + if len(msg.Undelegations) == 0 { + return nil, errors.New("batch undelegation must contain at least one undelegation") } - if len(msg.DelegationIndexes) != len(msg.Amounts) { - return nil, errors.New("delegation indexes and amounts must have the same length") + if len(msg.Undelegations) > staking.MaxBatchStakingActions { + return nil, staking.ErrBatchTooLarge } - allUpdatedWrappers := []*staking.ValidatorWrapper{} wrapperMap := map[common.Address]*staking.ValidatorWrapper{} + touched := []common.Address{} - for i, delegationIndex := range msg.DelegationIndexes { - amount := msg.Amounts[i] - if amount == nil || amount.Sign() == -1 { + for _, action := range msg.Undelegations { + amount := action.Amount + if amount == nil || amount.Sign() < 0 { return nil, errNegativeAmount } + if amount.Sign() == 0 { + return nil, errors.New("invalid amount, must be positive") + } - if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { + if !stateDB.IsValidator(action.ValidatorAddress) { return nil, errValidatorNotExist } - var wrapper *staking.ValidatorWrapper - var exists bool - if wrapper, exists = wrapperMap[delegationIndex.ValidatorAddress]; !exists { + wrapper, exists := wrapperMap[action.ValidatorAddress] + if !exists { var err error - wrapper, err = stateDB.ValidatorWrapper(delegationIndex.ValidatorAddress, false, true) + wrapper, err = stateDB.ValidatorWrapper(action.ValidatorAddress, false, true) if err != nil { return nil, err } - wrapperMap[delegationIndex.ValidatorAddress] = wrapper - } - - if uint64(len(wrapper.Delegations)) <= delegationIndex.Index { - utils.Logger().Warn(). - Str("validator", delegationIndex.ValidatorAddress.String()). - Uint64("delegation index", delegationIndex.Index). - Int("delegations length", len(wrapper.Delegations)). - Msg("Delegation index out of bound") - return nil, errors.New("Delegation index out of bound") + if err := checkValidatorWrapperAddressBinding( + chainConfig, epoch, action.ValidatorAddress, wrapper, + ); err != nil { + return nil, err + } + wrapperMap[action.ValidatorAddress] = wrapper + touched = append(touched, action.ValidatorAddress) } - delegation := &wrapper.Delegations[delegationIndex.Index] - if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { - return nil, errors.New("delegator address mismatch") + found := false + for i := range wrapper.Delegations { + delegation := &wrapper.Delegations[i] + if bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { + if err := delegation.Undelegate(epoch, amount); err != nil { + return nil, err + } + found = true + break + } } - - if err := delegation.Undelegate(epoch, amount); err != nil { - return nil, err + if !found { + return nil, errNoDelegationToUndelegate } } - for _, wrapper := range wrapperMap { + allUpdatedWrappers := make([]*staking.ValidatorWrapper, 0, len(touched)) + for _, addr := range touched { + wrapper := wrapperMap[addr] if err := wrapper.SanityCheck(); err != nil { if errors.Cause(err) == staking.ErrInvalidSelfDelegation { wrapper.Status = effective.Inactive @@ -714,19 +725,19 @@ func VerifyAndBatchUndelegateFromMsg( return allUpdatedWrappers, nil } -// VerifyAndUndelegateAllFromMsg verifies and prepares undelegation of all delegations -// for a delegator. It reads all delegations from the current state and creates a batch undelegation. -// This ensures delegations created in the same block are included. +// VerifyAndUndelegateAllFromMsg undelegates all active stake for a delegator. +// It uses the provided delegation indexes and scans validators for any others. // // Note that this function never updates the stateDB, it only reads from stateDB. +// The returned UndelegationAction slice lists every undelegation applied. func VerifyAndUndelegateAllFromMsg( stateDB vm.StateDB, epoch *big.Int, msg *staking.UndelegateAll, delegations []staking.DelegationIndex, chainContext ChainContext, chainConfig *params.ChainConfig, -) ([]*staking.ValidatorWrapper, error) { +) ([]*staking.ValidatorWrapper, []staking.UndelegationAction, error) { if stateDB == nil { - return nil, errStateDBIsMissing + return nil, nil, errStateDBIsMissing } if epoch == nil { - return nil, errEpochMissing + return nil, nil, errEpochMissing } if chainConfig == nil { if chainContext != nil { @@ -734,17 +745,16 @@ func VerifyAndUndelegateAllFromMsg( } } if chainConfig == nil { - return nil, errors.New("chain config is required") + return nil, nil, errors.New("chain config is required") } if !chainConfig.IsStakingV2(epoch) { - return nil, errors.New("undelegate all is only available in StakingV2 epoch") + return nil, nil, errors.New("undelegate all is only available in StakingV2 epoch") } - delegationIndexes := []staking.DelegationIndex{} - amounts := []*big.Int{} + actions := []staking.UndelegationAction{} processedValidators := map[common.Address]map[uint64]bool{} - // First, process delegations from the provided list (from previous block) + // Process delegations from the provided index list. for _, delegationIndex := range delegations { if !stateDB.IsValidator(delegationIndex.ValidatorAddress) { continue @@ -768,17 +778,18 @@ func VerifyAndUndelegateAllFromMsg( continue } - delegationIndexes = append(delegationIndexes, delegationIndex) - amounts = append(amounts, new(big.Int).Set(delegation.Amount)) + actions = append(actions, staking.UndelegationAction{ + ValidatorAddress: delegationIndex.ValidatorAddress, + Amount: new(big.Int).Set(delegation.Amount), + }) - // Track processed delegations to avoid duplicates if processedValidators[delegationIndex.ValidatorAddress] == nil { processedValidators[delegationIndex.ValidatorAddress] = make(map[uint64]bool) } processedValidators[delegationIndex.ValidatorAddress][delegationIndex.Index] = true } - // Then, scan all validators in current state to find any new delegations created in this block + // Scan validators for active delegations not already processed. if chainContext != nil { validatorList, err := chainContext.ReadValidatorList() if err == nil { @@ -792,7 +803,6 @@ func VerifyAndUndelegateAllFromMsg( continue } - // Check all delegations for this delegator for i := range wrapper.Delegations { delegation := &wrapper.Delegations[i] if !bytes.Equal(delegation.DelegatorAddress.Bytes(), msg.DelegatorAddress.Bytes()) { @@ -803,18 +813,14 @@ func VerifyAndUndelegateAllFromMsg( continue } - // Skip if already processed if processedValidators[validatorAddr] != nil && processedValidators[validatorAddr][uint64(i)] { continue } - // Found a new delegation (created in this block) - delegationIndexes = append(delegationIndexes, staking.DelegationIndex{ + actions = append(actions, staking.UndelegationAction{ ValidatorAddress: validatorAddr, - Index: uint64(i), - BlockNum: big.NewInt(0), + Amount: new(big.Int).Set(delegation.Amount), }) - amounts = append(amounts, new(big.Int).Set(delegation.Amount)) if processedValidators[validatorAddr] == nil { processedValidators[validatorAddr] = make(map[uint64]bool) @@ -825,21 +831,29 @@ func VerifyAndUndelegateAllFromMsg( } } - // If no delegations found and no chain context to scan, return error - if len(delegationIndexes) == 0 { + if len(actions) == 0 { if chainContext == nil && len(delegations) == 0 { - return nil, errors.New("no delegations to undelegate") + return nil, nil, errors.New("no delegations to undelegate") } - return nil, errors.New("no active delegations to undelegate") + return nil, nil, errors.New("no active delegations to undelegate") + } + if len(actions) > staking.MaxBatchStakingActions { + return nil, nil, errors.Errorf( + "undelegate all has %d active delegations; max is %d", + len(actions), staking.MaxBatchStakingActions, + ) } batchUndelegateMsg := &staking.BatchUndelegate{ - DelegatorAddress: msg.DelegatorAddress, - DelegationIndexes: delegationIndexes, - Amounts: amounts, + DelegatorAddress: msg.DelegatorAddress, + Undelegations: actions, } - return VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg, chainConfig) + wrappers, err := VerifyAndBatchUndelegateFromMsg(stateDB, epoch, batchUndelegateMsg, chainConfig) + if err != nil { + return nil, nil, err + } + return wrappers, actions, nil } // VerifyAndMigrateFromMsg verifies and transfers all delegations of diff --git a/core/staking_verifier_test.go b/core/staking_verifier_test.go index 0f3f52fc53..9df1626c34 100644 --- a/core/staking_verifier_test.go +++ b/core/staking_verifier_test.go @@ -2408,12 +2408,13 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { } tests := []struct { - name string - sdb vm.StateDB - epoch *big.Int - msg staking.BatchUndelegate - chainConfig *params.ChainConfig - expErr error + name string + sdb vm.StateDB + epoch *big.Int + msg staking.BatchUndelegate + chainConfig *params.ChainConfig + expErr error + checkAmounts bool }{ { name: "successful batch undelegate from two validators", @@ -2432,15 +2433,12 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, - {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, - }, - Amounts: []*big.Int{ - new(big.Int).Set(fiveKOnes), - new(big.Int).Set(fiveKOnes), + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr2, Amount: new(big.Int).Set(fiveKOnes)}, }, }, + checkAmounts: true, }, { name: "nil state db", @@ -2449,10 +2447,9 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errStateDBIsMissing, }, @@ -2463,10 +2460,9 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errEpochMissing, }, @@ -2481,39 +2477,22 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { }(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errors.New("batch undelegation is only available in StakingV2 epoch"), }, { - name: "empty delegation indexes", - sdb: makeDefaultStateForUndelegate(t), - epoch: epoch, - chainConfig: stakingV2Config(), - msg: staking.BatchUndelegate{ - DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{}, - Amounts: []*big.Int{}, - }, - expErr: errors.New("batch undelegation must contain at least one delegation index"), - }, - { - name: "mismatched lengths", + name: "empty undelegations", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, - {ValidatorAddress: validatorAddr2, Index: 1, BlockNum: big.NewInt(100)}, - }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, + Undelegations: []staking.UndelegationAction{}, }, - expErr: errors.New("delegation indexes and amounts must have the same length"), + expErr: errors.New("batch undelegation must contain at least one undelegation"), }, { name: "invalid validator", @@ -2522,10 +2501,9 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: makeTestAddr("not exist"), Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: makeTestAddr("not exist"), Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, expErr: errValidatorNotExist, }, @@ -2536,40 +2514,37 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: big.NewInt(-1)}, }, - Amounts: []*big.Int{big.NewInt(-1)}, }, expErr: errNegativeAmount, }, { - name: "delegation index out of bound", + name: "no delegation for delegator", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ - DelegatorAddress: delegatorAddr, - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 999, BlockNum: big.NewInt(100)}, + DelegatorAddress: makeTestAddr("wrong delegator"), + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, - expErr: errors.New("Delegation index out of bound"), + expErr: errNoDelegationToUndelegate, }, { - name: "delegator address mismatch", + name: "insufficient stake", sdb: makeDefaultStateForUndelegate(t), epoch: epoch, chainConfig: stakingV2Config(), msg: staking.BatchUndelegate{ - DelegatorAddress: makeTestAddr("wrong delegator"), - DelegationIndexes: []staking.DelegationIndex{ - {ValidatorAddress: validatorAddr, Index: 1, BlockNum: big.NewInt(100)}, + DelegatorAddress: delegatorAddr, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(hundredKOnes)}, }, - Amounts: []*big.Int{new(big.Int).Set(fiveKOnes)}, }, - expErr: errors.New("delegator address mismatch"), + expErr: errors.New("insufficient balance to undelegate"), }, } @@ -2587,6 +2562,33 @@ func TestVerifyAndBatchUndelegateFromMsg(t *testing.T) { if len(ws) == 0 { t.Errorf("Test %v: expected at least one wrapper", i) } + if test.checkAmounts { + for _, wrapper := range ws { + for _, del := range wrapper.Delegations { + if del.DelegatorAddress != delegatorAddr { + continue + } + if wrapper.Address == validatorAddr { + // started 20k, prior 5k undelegation left 15k, then another 5k -> 10k active + if del.Amount.Cmp(tenKOnes) != 0 { + t.Errorf("validator1 amount = %s, want 10k", del.Amount) + } + // prior same-epoch entry 5k + new 5k = 10k + if len(del.Undelegations) != 1 || del.Undelegations[0].Amount.Cmp(tenKOnes) != 0 { + t.Errorf("validator1 undelegation = %+v, want 10k single entry", del.Undelegations) + } + } + if wrapper.Address == validatorAddr2 { + if del.Amount.Cmp(fifteenKOnes) != 0 { + t.Errorf("validator2 amount = %s, want 15k", del.Amount) + } + if len(del.Undelegations) != 1 || del.Undelegations[0].Amount.Cmp(fiveKOnes) != 0 { + t.Errorf("validator2 undelegation = %+v, want 5k", del.Undelegations) + } + } + } + } + } }) } } @@ -2702,7 +2704,7 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { for i, test := range tests { t.Run(test.name, func(t *testing.T) { - ws, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain, nil) + ws, actions, err := VerifyAndUndelegateAllFromMsg(test.sdb, test.epoch, &test.msg, test.delegations, test.chain, nil) if assErr := assertError(err, test.expErr); assErr != nil { t.Errorf("Test %v: %v", i, assErr) @@ -2714,6 +2716,9 @@ func TestVerifyAndUndelegateAllFromMsg(t *testing.T) { if len(ws) == 0 { t.Errorf("Test %v: expected at least one wrapper", i) } + if len(actions) == 0 { + t.Errorf("Test %v: expected undelegation actions for Rosetta", i) + } }) } } @@ -2744,8 +2749,6 @@ func TestBatchDelegateRedelegationComposition(t *testing.T) { config.RedelegationEpoch = epoch config.MinDelegation100Epoch = big.NewInt(100) - // Two destinations share the same locked-token source. Composition must - // consume undelegations sequentially (not double-count from fresh copies). msg := staking.BatchDelegate{ DelegatorAddress: delegatorAddr, Delegations: []staking.DelegationAction{ @@ -2865,7 +2868,7 @@ func TestUndelegateAllRequiresStakingV2(t *testing.T) { config := ¶ms.ChainConfig{} config.StakingV2Epoch = big.NewInt(10000000) - _, err := VerifyAndUndelegateAllFromMsg( + _, _, err := VerifyAndUndelegateAllFromMsg( makeDefaultStateForUndelegate(t), epoch, &staking.UndelegateAll{DelegatorAddress: delegatorAddr}, @@ -2877,3 +2880,171 @@ func TestUndelegateAllRequiresStakingV2(t *testing.T) { t.Fatal(assErr) } } + +func TestBatchUndelegateSameValidatorTwice(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + + msg := staking.BatchUndelegate{ + DelegatorAddress: delegatorAddr, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fiveKOnes)}, + }, + } + ws, err := VerifyAndBatchUndelegateFromMsg(makeDefaultStateForUndelegate(t), epoch, &msg, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ws) != 1 { + t.Fatalf("expected 1 wrapper, got %d", len(ws)) + } + for _, del := range ws[0].Delegations { + if del.DelegatorAddress != delegatorAddr { + continue + } + // 20k - 5k prior - 5k - 5k = 5k active; undelegation 5k+5k+5k = 15k + if del.Amount.Cmp(fiveKOnes) != 0 { + t.Fatalf("active amount = %s, want 5k", del.Amount) + } + if len(del.Undelegations) != 1 || del.Undelegations[0].Amount.Cmp(fifteenKOnes) != 0 { + t.Fatalf("undelegation = %+v, want 15k merged", del.Undelegations) + } + } +} + +func TestBatchUndelegateSelfDelegationInactive(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + + msg := staking.BatchUndelegate{ + DelegatorAddress: validatorAddr, + Undelegations: []staking.UndelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(fifteenKOnes)}, + }, + } + ws, err := VerifyAndBatchUndelegateFromMsg(makeDefaultStateForUndelegate(t), epoch, &msg, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ws) != 1 { + t.Fatalf("expected 1 wrapper, got %d", len(ws)) + } + if ws[0].Status != effective.Inactive { + t.Fatalf("expected Inactive status, got %v", ws[0].Status) + } +} + +func TestBatchDelegateTooLarge(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + actions := make([]staking.DelegationAction, staking.MaxBatchStakingActions+1) + for i := range actions { + actions[i] = staking.DelegationAction{ + ValidatorAddress: validatorAddr, + Amount: new(big.Int).Set(fiveKOnes), + } + } + msg := staking.BatchDelegate{DelegatorAddress: delegatorAddr, Delegations: actions} + _, _, _, err := VerifyAndBatchDelegateFromMsg(makeStateDBForStake(t), epoch, &msg, nil, config) + if assErr := assertError(err, staking.ErrBatchTooLarge); assErr != nil { + t.Fatal(assErr) + } +} + +func TestBatchUndelegateTooLarge(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + + actions := make([]staking.UndelegationAction, staking.MaxBatchStakingActions+1) + for i := range actions { + actions[i] = staking.UndelegationAction{ + ValidatorAddress: validatorAddr, + Amount: big.NewInt(1), + } + } + msg := staking.BatchUndelegate{DelegatorAddress: delegatorAddr, Undelegations: actions} + _, err := VerifyAndBatchUndelegateFromMsg(makeDefaultStateForUndelegate(t), epoch, &msg, config) + if assErr := assertError(err, staking.ErrBatchTooLarge); assErr != nil { + t.Fatal(assErr) + } +} + +func TestBatchDelegateMaxTotalDelegation(t *testing.T) { + epoch := big.NewInt(defaultEpoch) + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + sdb := makeStateDBForStake(t) + w, err := sdb.ValidatorWrapper(validatorAddr, false, true) + if err != nil { + t.Fatal(err) + } + current := w.TotalDelegation() + w.MaxTotalDelegation = new(big.Int).Add(current, fiveKOnes) + if err := sdb.UpdateValidatorWrapper(validatorAddr, w); err != nil { + t.Fatal(err) + } + sdb.IntermediateRoot(true) + + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, + }, + } + _, _, _, err = VerifyAndBatchDelegateFromMsg(sdb, epoch, &msg, nil, config) + if err == nil { + t.Fatal("expected max total delegation error") + } +} + +func TestBatchDelegateMixedLiquidAndLocked(t *testing.T) { + epoch := big.NewInt(10) + oldEpoch := big.NewInt(5) + sdb := makeStateForRedelegateCornerCases(t, validatorAddr, []struct { + amount *big.Int + epoch *big.Int + }{ + {amount: new(big.Int).Set(fiveKOnes), epoch: oldEpoch}, + }) + sdb.SetBalance(delegatorAddr, new(big.Int).Set(fiveKOnes)) + + w, err := sdb.ValidatorWrapper(validatorAddr, false, false) + if err != nil { + t.Fatal(err) + } + delegationIndex := []staking.DelegationIndex{{ + ValidatorAddress: validatorAddr, + Index: uint64(len(w.Delegations) - 1), + BlockNum: big.NewInt(100), + }} + config := ¶ms.ChainConfig{} + config.StakingV2Epoch = epoch + config.RedelegationEpoch = epoch + config.MinDelegation100Epoch = big.NewInt(100) + + msg := staking.BatchDelegate{ + DelegatorAddress: delegatorAddr, + Delegations: []staking.DelegationAction{ + {ValidatorAddress: validatorAddr, Amount: new(big.Int).Set(tenKOnes)}, // 5k locked + 5k liquid + }, + } + _, balance, fromLocked, err := VerifyAndBatchDelegateFromMsg(sdb, epoch, &msg, delegationIndex, config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance.Cmp(fiveKOnes) != 0 { + t.Fatalf("liquid deduct = %s, want 5k", balance) + } + if fromLocked[validatorAddr].Cmp(fiveKOnes) != 0 { + t.Fatalf("locked used = %s, want 5k", fromLocked[validatorAddr]) + } +} diff --git a/core/state_transition.go b/core/state_transition.go index b688526d2a..8927ab2111 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -18,6 +18,7 @@ package core import ( "fmt" + "math" "math/big" "github.com/ethereum/go-ethereum/common" @@ -352,6 +353,29 @@ func (st *StateTransition) StakingTransitionDb() (usedGas uint64, err error) { if err != nil { return 0, err } + var batchDirective stakingTypes.Directive + hasBatchExtraGas := false + switch msg.Type() { + case types.BatchDelegate: + batchDirective = stakingTypes.DirectiveBatchDelegate + hasBatchExtraGas = true + case types.BatchUndelegate: + batchDirective = stakingTypes.DirectiveBatchUndelegate + hasBatchExtraGas = true + case types.UndelegateAll: + batchDirective = stakingTypes.DirectiveUndelegateAll + hasBatchExtraGas = true + } + if hasBatchExtraGas { + extra, extraErr := stakingTypes.ExtraGasForStakingDirective(batchDirective, st.data) + if extraErr != nil { + return 0, extraErr + } + if gas > math.MaxUint64-extra { + return 0, vm.ErrGasUintOverflow + } + gas += extra + } if err = st.useGas(gas); err != nil { return 0, err } diff --git a/core/tx_pool.go b/core/tx_pool.go index 2711a6e06b..b1cabd929d 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -770,6 +770,16 @@ func (pool *TxPool) validateTx(tx types.PoolTransaction, local bool) error { intrGas := uint64(0) if isStakingTx { intrGas, err = vm.IntrinsicGas(tx.Data(), false, pool.homestead, pool.istanbul, stakingTx.StakingType() == staking.DirectiveCreateValidator, pool.isEIP3860) + if err == nil { + extra, extraErr := staking.ExtraGasForStakingDirective(stakingTx.StakingType(), tx.Data()) + if extraErr != nil { + return extraErr + } + if intrGas > ^uint64(0)-extra { + return errors.New("staking batch gas overflow") + } + intrGas += extra + } } else { intrGas, err = vm.IntrinsicGas(tx.Data(), tx.To() == nil, pool.homestead, pool.istanbul, false, pool.isEIP3860) } @@ -1069,7 +1079,7 @@ func (pool *TxPool) validateStakingTx(tx *staking.StakingTransaction) error { if err != nil { return err } - _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain, pool.chainconfig) + _, _, err = VerifyAndUndelegateAllFromMsg(pool.currentState, pendingEpoch, stkMsg, delegations, chain, pool.chainconfig) return err default: return staking.ErrInvalidStakingKind diff --git a/internal/params/protocol_params.go b/internal/params/protocol_params.go index f79b2367d3..c54dc629dd 100644 --- a/internal/params/protocol_params.go +++ b/internal/params/protocol_params.go @@ -34,6 +34,10 @@ const ( TxGasContractCreation uint64 = 53000 // Per transaction that creates a contract. NOTE: Not payable on data of calls between transactions. // TxGasValidatorCreation ... TxGasValidatorCreation uint64 = 5300000 // Per transaction that creates a new validator. NOTE: Not payable on data of calls between transactions. + // TxGasPerBatchStakingAction is charged per BatchDelegate / BatchUndelegate action. + TxGasPerBatchStakingAction uint64 = 25000 + // TxGasUndelegateAll is charged for UndelegateAll. + TxGasUndelegateAll uint64 = 500000 // TxDataZeroGas ... TxDataZeroGas uint64 = 4 // Per byte of data attached to a transaction that equals zero. NOTE: Not payable on data of calls between transactions. // QuadCoeffDiv ... diff --git a/staking/types/gas.go b/staking/types/gas.go new file mode 100644 index 0000000000..5b00cde16b --- /dev/null +++ b/staking/types/gas.go @@ -0,0 +1,62 @@ +package types + +import ( + "math" + + "github.com/harmony-one/harmony/internal/params" + "github.com/pkg/errors" +) + +var ( + // ErrBatchTooLarge is returned when a batch staking tx exceeds MaxBatchStakingActions. + ErrBatchTooLarge = errors.New("batch staking action count exceeds maximum") +) + +// ExtraGasForStakingDirective returns gas for batch staking directives. +// data is the RLP-encoded stake message payload. +func ExtraGasForStakingDirective(directive Directive, data []byte) (uint64, error) { + switch directive { + case DirectiveBatchDelegate: + msg, err := RLPDecodeStakeMsg(data, DirectiveBatchDelegate) + if err != nil { + return 0, err + } + batch, ok := msg.(*BatchDelegate) + if !ok { + return 0, ErrInvalidStakingKind + } + n := len(batch.Delegations) + if n > MaxBatchStakingActions { + return 0, ErrBatchTooLarge + } + return mulGas(uint64(n), params.TxGasPerBatchStakingAction) + case DirectiveBatchUndelegate: + msg, err := RLPDecodeStakeMsg(data, DirectiveBatchUndelegate) + if err != nil { + return 0, err + } + batch, ok := msg.(*BatchUndelegate) + if !ok { + return 0, ErrInvalidStakingKind + } + n := len(batch.Undelegations) + if n > MaxBatchStakingActions { + return 0, ErrBatchTooLarge + } + return mulGas(uint64(n), params.TxGasPerBatchStakingAction) + case DirectiveUndelegateAll: + return params.TxGasUndelegateAll, nil + default: + return 0, nil + } +} + +func mulGas(count, per uint64) (uint64, error) { + if count == 0 { + return 0, nil + } + if per != 0 && count > math.MaxUint64/per { + return 0, errors.New("staking batch gas overflow") + } + return count * per, nil +} diff --git a/staking/types/gas_test.go b/staking/types/gas_test.go new file mode 100644 index 0000000000..c3d49a90f7 --- /dev/null +++ b/staking/types/gas_test.go @@ -0,0 +1,80 @@ +package types + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/harmony-one/harmony/internal/params" +) + +func TestExtraGasForStakingDirective(t *testing.T) { + delegator := common.HexToAddress("0x1") + validator := common.HexToAddress("0x2") + + batchDelegate := BatchDelegate{ + DelegatorAddress: delegator, + Delegations: []DelegationAction{ + {ValidatorAddress: validator, Amount: big.NewInt(1000)}, + {ValidatorAddress: validator, Amount: big.NewInt(2000)}, + }, + } + data, err := rlp.EncodeToBytes(batchDelegate) + if err != nil { + t.Fatal(err) + } + gas, err := ExtraGasForStakingDirective(DirectiveBatchDelegate, data) + if err != nil { + t.Fatal(err) + } + want := 2 * params.TxGasPerBatchStakingAction + if gas != want { + t.Fatalf("batch delegate gas = %d, want %d", gas, want) + } + + batchUndelegate := BatchUndelegate{ + DelegatorAddress: delegator, + Undelegations: []UndelegationAction{ + {ValidatorAddress: validator, Amount: big.NewInt(1000)}, + }, + } + data, err = rlp.EncodeToBytes(batchUndelegate) + if err != nil { + t.Fatal(err) + } + gas, err = ExtraGasForStakingDirective(DirectiveBatchUndelegate, data) + if err != nil { + t.Fatal(err) + } + if gas != params.TxGasPerBatchStakingAction { + t.Fatalf("batch undelegate gas = %d, want %d", gas, params.TxGasPerBatchStakingAction) + } + + gas, err = ExtraGasForStakingDirective(DirectiveUndelegateAll, nil) + if err != nil { + t.Fatal(err) + } + if gas != params.TxGasUndelegateAll { + t.Fatalf("undelegate all gas = %d, want %d", gas, params.TxGasUndelegateAll) + } + + tooLarge := BatchDelegate{ + DelegatorAddress: delegator, + Delegations: make([]DelegationAction, MaxBatchStakingActions+1), + } + for i := range tooLarge.Delegations { + tooLarge.Delegations[i] = DelegationAction{ + ValidatorAddress: validator, + Amount: big.NewInt(1), + } + } + data, err = rlp.EncodeToBytes(tooLarge) + if err != nil { + t.Fatal(err) + } + _, err = ExtraGasForStakingDirective(DirectiveBatchDelegate, data) + if err != ErrBatchTooLarge { + t.Fatalf("expected ErrBatchTooLarge, got %v", err) + } +} diff --git a/staking/types/messages.go b/staking/types/messages.go index 1102f83868..aa4ef232cf 100644 --- a/staking/types/messages.go +++ b/staking/types/messages.go @@ -332,11 +332,20 @@ func (v BatchDelegate) Equals(s BatchDelegate) bool { return true } +// MaxBatchStakingActions is the maximum number of actions allowed in a single +// BatchDelegate or BatchUndelegate transaction. +const MaxBatchStakingActions = 50 + +// UndelegationAction represents a single undelegation action in a batch operation +type UndelegationAction struct { + ValidatorAddress common.Address `json:"validator_address"` + Amount *big.Int `json:"amount"` +} + // BatchUndelegate - type for undelegating from multiple validators in one transaction type BatchUndelegate struct { - DelegatorAddress common.Address `json:"delegator_address"` - DelegationIndexes []DelegationIndex `json:"delegation_indexes"` - Amounts []*big.Int `json:"amounts"` + DelegatorAddress common.Address `json:"delegator_address"` + Undelegations []UndelegationAction `json:"undelegations"` } // Type of BatchUndelegate @@ -347,22 +356,15 @@ func (v BatchUndelegate) Type() Directive { // Copy returns a deep copy of the BatchUndelegate as a StakeMsg interface func (v BatchUndelegate) Copy() StakeMsg { cp := BatchUndelegate{ - DelegatorAddress: v.DelegatorAddress, - DelegationIndexes: make([]DelegationIndex, len(v.DelegationIndexes)), - Amounts: make([]*big.Int, len(v.Amounts)), - } - for i, idx := range v.DelegationIndexes { - cp.DelegationIndexes[i] = DelegationIndex{ - ValidatorAddress: idx.ValidatorAddress, - Index: idx.Index, - } - if idx.BlockNum != nil { - cp.DelegationIndexes[i].BlockNum = new(big.Int).Set(idx.BlockNum) - } + DelegatorAddress: v.DelegatorAddress, + Undelegations: make([]UndelegationAction, len(v.Undelegations)), } - for i, amt := range v.Amounts { - if amt != nil { - cp.Amounts[i] = new(big.Int).Set(amt) + for i, u := range v.Undelegations { + cp.Undelegations[i] = UndelegationAction{ + ValidatorAddress: u.ValidatorAddress, + } + if u.Amount != nil { + cp.Undelegations[i].Amount = new(big.Int).Set(u.Amount) } } return cp @@ -373,28 +375,20 @@ func (v BatchUndelegate) Equals(s BatchUndelegate) bool { if !bytes.Equal(v.DelegatorAddress.Bytes(), s.DelegatorAddress.Bytes()) { return false } - if len(v.DelegationIndexes) != len(s.DelegationIndexes) { - return false - } - if len(v.Amounts) != len(s.Amounts) { + if len(v.Undelegations) != len(s.Undelegations) { return false } - for i := range v.DelegationIndexes { - if !bytes.Equal(v.DelegationIndexes[i].ValidatorAddress.Bytes(), s.DelegationIndexes[i].ValidatorAddress.Bytes()) { + for i := range v.Undelegations { + if !bytes.Equal(v.Undelegations[i].ValidatorAddress.Bytes(), s.Undelegations[i].ValidatorAddress.Bytes()) { return false } - if v.DelegationIndexes[i].Index != s.DelegationIndexes[i].Index { - return false - } - } - for i := range v.Amounts { - if v.Amounts[i] == nil { - if s.Amounts[i] != nil { + if v.Undelegations[i].Amount == nil { + if s.Undelegations[i].Amount != nil { return false } - } else if s.Amounts[i] == nil { + } else if s.Undelegations[i].Amount == nil { return false - } else if v.Amounts[i].Cmp(s.Amounts[i]) != 0 { + } else if v.Undelegations[i].Amount.Cmp(s.Undelegations[i].Amount) != 0 { return false } } From ecf96cf1efb10c94e2b275b3552e29c052b0f7df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:09:35 +0000 Subject: [PATCH 23/23] Rebase feature branch onto dev and resolve conflicts --- internal/params/config.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/params/config.go b/internal/params/config.go index 95a39ef71e..f7027dff0a 100644 --- a/internal/params/config.go +++ b/internal/params/config.go @@ -105,6 +105,7 @@ var ( SlashBallotSignerFixEpoch: big.NewInt(2964), VerifyBeaconHeaderSlashEpoch: big.NewInt(2964), BloomEpoch: big.NewInt(2964), + StakingV2Epoch: EpochTBD, } // TestnetChainConfig contains the chain parameters to run a node on the harmony test network. @@ -179,6 +180,7 @@ var ( CXMerkleProofReplayFixEpoch: big.NewInt(7385), BLSProofBindEpoch: big.NewInt(7420), BloomEpoch: big.NewInt(7414), + StakingV2Epoch: EpochTBD, } // PangaeaChainConfig contains the chain parameters for the Pangaea network. // All features except for CrossLink are enabled at launch. @@ -251,6 +253,7 @@ var ( SlashBallotSignerFixEpoch: EpochTBD, VerifyBeaconHeaderSlashEpoch: EpochTBD, BloomEpoch: EpochTBD, + StakingV2Epoch: EpochTBD, } // PartnerChainConfig contains the chain parameters for the Partner network. @@ -325,6 +328,7 @@ var ( SlashBallotSignerFixEpoch: big.NewInt(52650), VerifyBeaconHeaderSlashEpoch: big.NewInt(53000), BloomEpoch: big.NewInt(53508), + StakingV2Epoch: EpochTBD, } // StressnetChainConfig contains the chain parameters for the Stress test network. @@ -398,6 +402,7 @@ var ( SlashBallotSignerFixEpoch: EpochTBD, VerifyBeaconHeaderSlashEpoch: EpochTBD, BloomEpoch: EpochTBD, + StakingV2Epoch: EpochTBD, } // LocalnetChainConfig contains the chain parameters to run for local development. @@ -547,6 +552,7 @@ var ( big.NewInt(1), // SlashBallotSignerFixEpoch big.NewInt(1), // VerifyBeaconHeaderSlashEpoch big.NewInt(1), // BloomEpoch + big.NewInt(0), // StakingV2Epoch } // TestChainConfig ... @@ -884,6 +890,8 @@ type ChainConfig struct { // have individual activation epochs; each feature is active once the chain // reaches the earlier of BloomEpoch and that feature's epoch. BloomEpoch *big.Int `json:"bloom-epoch,omitempty"` + // StakingV2Epoch is the epoch when Staking V2 is activated. + StakingV2Epoch *big.Int `json:"staking-v2-epoch,omitempty"` } // String implements the fmt.Stringer interface. @@ -1306,6 +1314,10 @@ func (c *ChainConfig) IsPrague(epoch *big.Int) bool { return isForked(c.PragueEpoch, epoch) } +func (c *ChainConfig) IsStakingV2(epoch *big.Int) bool { + return isForked(c.StakingV2Epoch, epoch) +} + // During this epoch, shards 2 and 3 will start sending // their balances over to shard 0 or 1. func (c *ChainConfig) IsOneEpochBeforeHIP30(epoch *big.Int) bool {