diff --git a/command/polybft/polybft_command.go b/command/polybft/polybft_command.go index c6ad1554..e37ec579 100644 --- a/command/polybft/polybft_command.go +++ b/command/polybft/polybft_command.go @@ -11,6 +11,7 @@ import ( "github.com/0xPolygon/polygon-edge/command/sidechain/commission" "github.com/0xPolygon/polygon-edge/command/sidechain/rewards" "github.com/0xPolygon/polygon-edge/command/sidechain/unstaking" + vestingstatus "github.com/0xPolygon/polygon-edge/command/sidechain/vesting-status" sidechainWithdraw "github.com/0xPolygon/polygon-edge/command/sidechain/withdraw" "github.com/spf13/cobra" ) @@ -40,6 +41,8 @@ func GetCommand() *cobra.Command { terminateban.GetCommand(), // sidechain (hydra delegation) command to set commission commission.GetCommand(), + // sidechain (hydra staking) command to check vesting status + vestingstatus.GetCommand(), ) return polybftCmd diff --git a/command/sidechain/vesting-status/params.go b/command/sidechain/vesting-status/params.go new file mode 100644 index 00000000..15dec522 --- /dev/null +++ b/command/sidechain/vesting-status/params.go @@ -0,0 +1,98 @@ +package vestingstatus + +import ( + "bytes" + "fmt" + "math/big" + "time" + + "github.com/0xPolygon/polygon-edge/command/helper" + sidechainHelper "github.com/0xPolygon/polygon-edge/command/sidechain" +) + +type vestingStatusParams struct { + accountDir string + accountConfig string + jsonRPC string + insecureLocalStore bool +} + +func (v *vestingStatusParams) validateFlags() error { + if _, err := helper.ParseJSONRPCAddress(v.jsonRPC); err != nil { + return fmt.Errorf("failed to parse json rpc address. Error: %w", err) + } + + return sidechainHelper.ValidateSecretFlags(v.accountDir, v.accountConfig) +} + +type vestingStatusResult struct { + ValidatorAddress string `json:"validatorAddress"` + VestingDuration string `json:"vestingDuration"` + VestingStart string `json:"vestingStart"` + VestingEnd string `json:"vestingEnd"` + BaseStake string `json:"baseStake"` + VestBonus string `json:"vestBonus"` + RSIBonus string `json:"rsiBonus"` + GeneratedRewards string `json:"generatedRewards"` + UnclaimedRewards string `json:"unclaimedRewards"` + ClaimableCommissions string `json:"claimableCommissions"` + IsActiveVestingPosition bool `json:"isActiveVestingPosition"` +} + +func formatTimestamp(ts *big.Int) string { + if ts == nil || ts.Sign() == 0 { + return "N/A" + } + + t := time.Unix(ts.Int64(), 0).UTC() + + return fmt.Sprintf("%s (%s)", ts.String(), t.Format(time.RFC3339)) +} + +func formatWei(wei *big.Int) string { + if wei == nil { + return "0" + } + + return wei.String() +} + +func (vr vestingStatusResult) GetOutput() string { + var buffer bytes.Buffer + + if !vr.IsActiveVestingPosition { + buffer.WriteString("\n[VESTING STATUS - NO ACTIVE POSITION]\n") + + vals := []string{ + fmt.Sprintf("Validator Address|%s", vr.ValidatorAddress), + fmt.Sprintf("Generated Rewards (wei)|%s", vr.GeneratedRewards), + fmt.Sprintf("Unclaimed Rewards (wei)|%s", vr.UnclaimedRewards), + fmt.Sprintf("Claimable Commissions (wei)|%s", vr.ClaimableCommissions), + } + + buffer.WriteString(helper.FormatKV(vals)) + buffer.WriteString("\n") + + return buffer.String() + } + + buffer.WriteString("\n[VESTING STATUS]\n") + + vals := []string{ + fmt.Sprintf("Validator Address|%s", vr.ValidatorAddress), + fmt.Sprintf("Vesting Duration (weeks)|%s", vr.VestingDuration), + fmt.Sprintf("Vesting Start|%s", vr.VestingStart), + fmt.Sprintf("Vesting End|%s", vr.VestingEnd), + fmt.Sprintf("Base Stake (wei)|%s", vr.BaseStake), + fmt.Sprintf("Vest Bonus (wei)|%s", vr.VestBonus), + fmt.Sprintf("RSI Bonus (wei)|%s", vr.RSIBonus), + fmt.Sprintf("Generated Rewards (wei)|%s", vr.GeneratedRewards), + fmt.Sprintf("Unclaimed Rewards (wei)|%s", vr.UnclaimedRewards), + fmt.Sprintf("Claimable Commissions (wei)|%s", vr.ClaimableCommissions), + } + + buffer.WriteString(helper.FormatKV(vals)) + buffer.WriteString("\n") + + return buffer.String() +} diff --git a/command/sidechain/vesting-status/params_test.go b/command/sidechain/vesting-status/params_test.go new file mode 100644 index 00000000..48ad98f3 --- /dev/null +++ b/command/sidechain/vesting-status/params_test.go @@ -0,0 +1,198 @@ +package vestingstatus + +import ( + "math/big" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_formatTimestamp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *big.Int + expected string + }{ + { + name: "nil returns N/A", + input: nil, + expected: "N/A", + }, + { + name: "zero returns N/A", + input: big.NewInt(0), + expected: "N/A", + }, + { + name: "valid unix timestamp", + input: big.NewInt(1700000000), + expected: "1700000000 (" + time.Unix(1700000000, 0).UTC().Format(time.RFC3339) + ")", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, formatTimestamp(tt.input)) + }) + } +} + +func Test_formatWei(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input *big.Int + expected string + }{ + { + name: "nil returns 0", + input: nil, + expected: "0", + }, + { + name: "zero returns 0", + input: big.NewInt(0), + expected: "0", + }, + { + name: "positive value", + input: big.NewInt(1000000000000000000), + expected: "1000000000000000000", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, formatWei(tt.input)) + }) + } +} + +func Test_validateFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params vestingStatusParams + expectErr bool + }{ + { + name: "valid flags with account dir", + params: vestingStatusParams{ + jsonRPC: "http://localhost:10002", + accountDir: "/tmp/test-data", + }, + expectErr: false, + }, + { + name: "empty jsonrpc returns error", + params: vestingStatusParams{ + jsonRPC: "", + accountDir: "/tmp/test-data", + }, + expectErr: true, + }, + { + name: "no account dir or config returns error", + params: vestingStatusParams{ + jsonRPC: "http://localhost:10002", + }, + expectErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.params.validateFlags() + if tt.expectErr { + require.Error(t, err) + } else { + // ValidateSecretFlags calls CheckIfDirectoryExist which may fail + // if the dir doesn't exist. We only check that jsonRPC parsing succeeded. + if err != nil { + assert.NotContains(t, err.Error(), "failed to parse json rpc address") + } + } + }) + } +} + +func Test_GetOutput_ActivePosition(t *testing.T) { + t.Parallel() + + result := vestingStatusResult{ + ValidatorAddress: "0x1234567890abcdef", + VestingDuration: "52", + VestingStart: "1700000000 (2023-11-14T22:13:20Z)", + VestingEnd: "1731536000 (2024-11-14T00:00:00Z)", + BaseStake: "1000000000000000000", + VestBonus: "500000000000000000", + RSIBonus: "200000000000000000", + GeneratedRewards: "300000000000000000", + UnclaimedRewards: "100000000000000000", + ClaimableCommissions: "50000000000000000", + IsActiveVestingPosition: true, + } + + output := result.GetOutput() + + assert.Contains(t, output, "[VESTING STATUS]") + assert.NotContains(t, output, "NO ACTIVE POSITION") + assert.Contains(t, output, "0x1234567890abcdef") + assert.Contains(t, output, "Vesting Duration (weeks)") + assert.Contains(t, output, "Base Stake (wei)") + assert.Contains(t, output, "Generated Rewards (wei)") + assert.Contains(t, output, "Unclaimed Rewards (wei)") + assert.Contains(t, output, "Claimable Commissions (wei)") +} + +func Test_GetOutput_InactivePosition(t *testing.T) { + t.Parallel() + + result := vestingStatusResult{ + ValidatorAddress: "0xdeadbeef", + GeneratedRewards: "42000", + UnclaimedRewards: "100", + ClaimableCommissions: "200", + IsActiveVestingPosition: false, + } + + output := result.GetOutput() + + assert.Contains(t, output, "[VESTING STATUS - NO ACTIVE POSITION]") + assert.Contains(t, output, "0xdeadbeef") + assert.Contains(t, output, "Generated Rewards (wei)") + assert.Contains(t, output, "42000") + assert.Contains(t, output, "Unclaimed Rewards (wei)") + assert.Contains(t, output, "Claimable Commissions (wei)") + + // Active-only fields should NOT be in inactive output + assert.NotContains(t, output, "Vesting Duration") + assert.NotContains(t, output, "Base Stake") + assert.NotContains(t, output, "Vest Bonus") + + // Verify field count: should have exactly 4 fields (Validator Address, Generated Rewards, Unclaimed Rewards, Claimable Commissions) + lines := strings.Split(strings.TrimSpace(output), "\n") + // First line is header, rest are KV pairs + kvLines := 0 + for _, line := range lines { + if strings.Contains(line, "=") { + kvLines++ + } + } + + assert.Equal(t, 4, kvLines, "inactive output should have exactly 4 KV pairs") +} diff --git a/command/sidechain/vesting-status/vesting_status.go b/command/sidechain/vesting-status/vesting_status.go new file mode 100644 index 00000000..e554aeae --- /dev/null +++ b/command/sidechain/vesting-status/vesting_status.go @@ -0,0 +1,243 @@ +package vestingstatus + +import ( + "fmt" + "math/big" + + "github.com/0xPolygon/polygon-edge/command" + "github.com/0xPolygon/polygon-edge/command/helper" + "github.com/0xPolygon/polygon-edge/command/polybftsecrets" + "github.com/0xPolygon/polygon-edge/command/sidechain" + "github.com/0xPolygon/polygon-edge/consensus/polybft/contractsapi" + "github.com/0xPolygon/polygon-edge/contracts" + "github.com/0xPolygon/polygon-edge/helper/hex" + "github.com/0xPolygon/polygon-edge/txrelayer" + "github.com/spf13/cobra" + "github.com/umbracle/ethgo" + "github.com/umbracle/ethgo/abi" +) + +var ( + params vestingStatusParams + + vestedStakingPositionsFn = contractsapi.HydraStaking.Abi.Methods["vestedStakingPositions"] + calculatePositionTotalRewardFn = contractsapi.HydraStaking.Abi.Methods["calculatePositionTotalReward"] + unclaimedRewardsFn = contractsapi.HydraStaking.Abi.Methods["unclaimedRewards"] + distributedCommissionsFn = contractsapi.HydraDelegation.Abi.Methods["distributedCommissions"] +) + +func GetCommand() *cobra.Command { + vestingStatusCmd := &cobra.Command{ + Use: "vesting-status", + Short: "Displays the vesting status of a validator, including vesting period, rewards, and commissions", + PreRunE: runPreRun, + RunE: runCommand, + } + + setFlags(vestingStatusCmd) + + return vestingStatusCmd +} + +func setFlags(cmd *cobra.Command) { + cmd.Flags().StringVar( + ¶ms.accountDir, + polybftsecrets.AccountDirFlag, + "", + polybftsecrets.AccountDirFlagDesc, + ) + + cmd.Flags().StringVar( + ¶ms.accountConfig, + polybftsecrets.AccountConfigFlag, + "", + polybftsecrets.AccountConfigFlagDesc, + ) + + cmd.Flags().BoolVar( + ¶ms.insecureLocalStore, + sidechain.InsecureLocalStoreFlag, + false, + "a flag to indicate if the secrets used are encrypted. If set to true, the secrets are stored in plain text.", + ) + + helper.RegisterJSONRPCFlag(cmd) + + cmd.MarkFlagsMutuallyExclusive(polybftsecrets.AccountDirFlag, polybftsecrets.AccountConfigFlag) +} + +func runPreRun(cmd *cobra.Command, _ []string) error { + params.jsonRPC = helper.GetJSONRPCAddress(cmd) + + return params.validateFlags() +} + +func runCommand(cmd *cobra.Command, _ []string) error { + outputter := command.InitializeOutputter(cmd) + defer outputter.WriteOutput() + + validatorAccount, err := sidechain.GetAccount(params.accountDir, params.accountConfig, params.insecureLocalStore) + if err != nil { + return err + } + + validatorAddr := validatorAccount.Ecdsa.Address() + + txRelayer, err := txrelayer.NewTxRelayer( + txrelayer.WithIPAddress(params.jsonRPC), + ) + if err != nil { + return err + } + + result, err := getVestingStatus(txRelayer, validatorAddr) + if err != nil { + return err + } + + outputter.WriteCommandResult(result) + + return nil +} + +// getVestingStatus queries all vesting-related contract state for a validator and returns the result. +func getVestingStatus(txRelayer txrelayer.TxRelayer, validatorAddr ethgo.Address) (*vestingStatusResult, error) { + result := &vestingStatusResult{ + ValidatorAddress: validatorAddr.String(), + } + + // 1. Query vestedStakingPositions from HydraStaking + vestingPosition, err := queryVestedStakingPositions(txRelayer, validatorAddr) + if err != nil { + return nil, fmt.Errorf("failed to query vesting position: %w", err) + } + + duration := vestingPosition["duration"].(*big.Int) //nolint:forcetypeassert + start := vestingPosition["start"].(*big.Int) //nolint:forcetypeassert + end := vestingPosition["end"].(*big.Int) //nolint:forcetypeassert + base := vestingPosition["base"].(*big.Int) //nolint:forcetypeassert + vestBonus := vestingPosition["vestBonus"].(*big.Int) //nolint:forcetypeassert + rsiBonus := vestingPosition["rsiBonus"].(*big.Int) //nolint:forcetypeassert + + result.IsActiveVestingPosition = start.Sign() > 0 + // Contract stores duration in seconds (durationWeeks * 1 weeks); convert back to weeks + durationWeeks := new(big.Int).Div(duration, big.NewInt(604800)) + result.VestingDuration = durationWeeks.String() + result.VestingStart = formatTimestamp(start) + result.VestingEnd = formatTimestamp(end) + result.BaseStake = formatWei(base) + result.VestBonus = formatWei(vestBonus) + result.RSIBonus = formatWei(rsiBonus) + + // 2. Query calculatePositionTotalReward from HydraStaking + totalReward, err := querySingleUint256( + txRelayer, validatorAddr, + calculatePositionTotalRewardFn, + (ethgo.Address)(contracts.HydraStakingContract), + ) + if err != nil { + return nil, fmt.Errorf("failed to query total reward: %w", err) + } + + result.GeneratedRewards = formatWei(totalReward) + + // 3. Query unclaimedRewards from HydraStaking + unclaimed, err := querySingleUint256( + txRelayer, validatorAddr, + unclaimedRewardsFn, + (ethgo.Address)(contracts.HydraStakingContract), + ) + if err != nil { + return nil, fmt.Errorf("failed to query unclaimed rewards: %w", err) + } + + result.UnclaimedRewards = formatWei(unclaimed) + + // 4. Query distributedCommissions from HydraDelegation + // Note: distributedCommissions returns the currently claimable (pending) commissions, + // not the total historical commissions ever distributed. + commissions, err := querySingleUint256( + txRelayer, validatorAddr, + distributedCommissionsFn, + (ethgo.Address)(contracts.HydraDelegationContract), + ) + if err != nil { + return nil, fmt.Errorf("failed to query distributed commissions: %w", err) + } + + result.ClaimableCommissions = formatWei(commissions) + + return result, nil +} + +// queryVestedStakingPositions calls the vestedStakingPositions view function on HydraStaking +func queryVestedStakingPositions( + txRelayer txrelayer.TxRelayer, validatorAddr ethgo.Address, +) (map[string]interface{}, error) { + encoded, err := vestedStakingPositionsFn.Encode([]interface{}{validatorAddr}) + if err != nil { + return nil, fmt.Errorf("failed to encode vestedStakingPositions call: %w", err) + } + + response, err := txRelayer.Call(validatorAddr, (ethgo.Address)(contracts.HydraStakingContract), encoded) + if err != nil { + return nil, err + } + + byteResponse, err := hex.DecodeHex(response) + if err != nil { + return nil, fmt.Errorf("unable to decode hex response: %w", err) + } + + decoded, err := vestedStakingPositionsFn.Outputs.Decode(byteResponse) + if err != nil { + return nil, err + } + + decodedMap, ok := decoded.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("could not convert decoded outputs to map") + } + + return decodedMap, nil +} + +// querySingleUint256 calls a view function that takes a single address and returns a single uint256 +func querySingleUint256( + txRelayer txrelayer.TxRelayer, + validatorAddr ethgo.Address, + method *abi.Method, + contractAddr ethgo.Address, +) (*big.Int, error) { + encoded, err := method.Encode([]interface{}{validatorAddr}) + if err != nil { + return nil, fmt.Errorf("failed to encode %s call: %w", method.Name, err) + } + + response, err := txRelayer.Call(validatorAddr, contractAddr, encoded) + if err != nil { + return nil, err + } + + byteResponse, err := hex.DecodeHex(response) + if err != nil { + return nil, fmt.Errorf("unable to decode hex response: %w", err) + } + + decoded, err := method.Outputs.Decode(byteResponse) + if err != nil { + return nil, err + } + + decodedMap, ok := decoded.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("could not convert decoded outputs to map") + } + + value, ok := decodedMap["0"].(*big.Int) + if !ok { + return nil, fmt.Errorf("could not convert output to big.Int") + } + + return value, nil +} diff --git a/command/sidechain/vesting-status/vesting_status_test.go b/command/sidechain/vesting-status/vesting_status_test.go new file mode 100644 index 00000000..c7f6f453 --- /dev/null +++ b/command/sidechain/vesting-status/vesting_status_test.go @@ -0,0 +1,317 @@ +package vestingstatus + +import ( + "errors" + "math/big" + "testing" + + "github.com/0xPolygon/polygon-edge/helper/hex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/umbracle/ethgo" + "github.com/umbracle/ethgo/abi" + "github.com/umbracle/ethgo/jsonrpc" +) + +// mockTxRelayer implements txrelayer.TxRelayer for testing +type mockTxRelayer struct { + mock.Mock +} + +func (m *mockTxRelayer) Call(from ethgo.Address, to ethgo.Address, input []byte) (string, error) { + args := m.Called(from, to, input) + + return args.String(0), args.Error(1) +} + +func (m *mockTxRelayer) SendTransaction(txn *ethgo.Transaction, key ethgo.Key) (*ethgo.Receipt, error) { + args := m.Called(txn, key) + + return args.Get(0).(*ethgo.Receipt), args.Error(1) //nolint:forcetypeassert +} + +func (m *mockTxRelayer) SendTransactionLocal(txn *ethgo.Transaction) (*ethgo.Receipt, error) { + args := m.Called(txn) + + return args.Get(0).(*ethgo.Receipt), args.Error(1) //nolint:forcetypeassert +} + +func (m *mockTxRelayer) Client() *jsonrpc.Client { + args := m.Called() + + return args.Get(0).(*jsonrpc.Client) //nolint:forcetypeassert +} + +var ( + testAddr = ethgo.Address{0x01, 0x02, 0x03} + + // ABI type matching the vestedStakingPositions return struct + vestingPositionABI = abi.MustNewType( + "tuple(uint256 duration, uint256 start, uint256 end, uint256 base, uint256 vestBonus, uint256 rsiBonus, uint256 commission)", + ) + + // ABI type for single uint256 return + uint256ABI = abi.MustNewType("uint256") +) + +// encodeVestingPosition ABI-encodes a vesting position tuple and returns a hex string +func encodeVestingPosition(t *testing.T, duration, start, end, base, vestBonus, rsiBonus, commission *big.Int) string { + t.Helper() + + encoded, err := vestingPositionABI.Encode(map[string]interface{}{ + "duration": duration, + "start": start, + "end": end, + "base": base, + "vestBonus": vestBonus, + "rsiBonus": rsiBonus, + "commission": commission, + }) + require.NoError(t, err) + + return hex.EncodeToHex(encoded) +} + +// encodeUint256 ABI-encodes a single uint256 value and returns a hex string +func encodeUint256(t *testing.T, value *big.Int) string { + t.Helper() + + encoded, err := uint256ABI.Encode(value) + require.NoError(t, err) + + return hex.EncodeToHex(encoded) +} + +func Test_queryVestedStakingPositions_ActivePosition(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + response := encodeVestingPosition(t, + big.NewInt(52), // duration + big.NewInt(1700000000), // start + big.NewInt(1731536000), // end + big.NewInt(1000), // base + big.NewInt(500), // vestBonus + big.NewInt(200), // rsiBonus + big.NewInt(10), // commission + ) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(response, nil).Once() + + result, err := queryVestedStakingPositions(relayer, testAddr) + require.NoError(t, err) + + assert.Equal(t, big.NewInt(52), result["duration"]) + assert.Equal(t, big.NewInt(1700000000), result["start"]) + assert.Equal(t, big.NewInt(1731536000), result["end"]) + assert.Equal(t, big.NewInt(1000), result["base"]) + assert.Equal(t, big.NewInt(500), result["vestBonus"]) + assert.Equal(t, big.NewInt(200), result["rsiBonus"]) + assert.Equal(t, big.NewInt(10), result["commission"]) + + relayer.AssertExpectations(t) +} + +func Test_queryVestedStakingPositions_NoPosition(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + // All zeros = no vesting position + response := encodeVestingPosition(t, + big.NewInt(0), big.NewInt(0), big.NewInt(0), + big.NewInt(0), big.NewInt(0), big.NewInt(0), big.NewInt(0), + ) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(response, nil).Once() + + result, err := queryVestedStakingPositions(relayer, testAddr) + require.NoError(t, err) + + start := result["start"].(*big.Int) //nolint:forcetypeassert + assert.Equal(t, 0, start.Sign(), "start should be zero for no position") + + relayer.AssertExpectations(t) +} + +func Test_queryVestedStakingPositions_CallError(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything). + Return("", errors.New("rpc connection refused")).Once() + + _, err := queryVestedStakingPositions(relayer, testAddr) + require.Error(t, err) + assert.Contains(t, err.Error(), "rpc connection refused") + + relayer.AssertExpectations(t) +} + +func Test_querySingleUint256_ValidResponse(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + expected := big.NewInt(42000) + response := encodeUint256(t, expected) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(response, nil).Once() + + result, err := querySingleUint256(relayer, testAddr, unclaimedRewardsFn, ethgo.Address{}) + require.NoError(t, err) + assert.Equal(t, expected, result) + + relayer.AssertExpectations(t) +} + +func Test_querySingleUint256_ZeroResponse(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + response := encodeUint256(t, big.NewInt(0)) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(response, nil).Once() + + result, err := querySingleUint256(relayer, testAddr, unclaimedRewardsFn, ethgo.Address{}) + require.NoError(t, err) + assert.Equal(t, 0, result.Sign(), "result should be zero") + + relayer.AssertExpectations(t) +} + +func Test_querySingleUint256_CallError(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything). + Return("", errors.New("timeout")).Once() + + _, err := querySingleUint256(relayer, testAddr, unclaimedRewardsFn, ethgo.Address{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") + + relayer.AssertExpectations(t) +} + +func Test_getVestingStatus_ActivePosition(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + // 1. vestedStakingPositions → active position + // Contract stores duration in seconds: 52 weeks * 604800 = 31449600 + vestingResp := encodeVestingPosition(t, + big.NewInt(31449600), + big.NewInt(1700000000), + big.NewInt(1731536000), + big.NewInt(1000), + big.NewInt(500), + big.NewInt(200), + big.NewInt(10), + ) + + // 2. calculatePositionTotalReward → 300 + totalRewardResp := encodeUint256(t, big.NewInt(300)) + + // 3. unclaimedRewards → 100 + unclaimedResp := encodeUint256(t, big.NewInt(100)) + + // 4. distributedCommissions → 50 + commissionsResp := encodeUint256(t, big.NewInt(50)) + + // Set up mock calls in order (each matches by the encoded input which differs per method) + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(vestingResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(totalRewardResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(unclaimedResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(commissionsResp, nil).Once() + + result, err := getVestingStatus(relayer, testAddr) + require.NoError(t, err) + + assert.True(t, result.IsActiveVestingPosition) + assert.Equal(t, testAddr.String(), result.ValidatorAddress) + assert.Equal(t, "52", result.VestingDuration) + assert.Contains(t, result.VestingStart, "1700000000") + assert.Contains(t, result.VestingEnd, "1731536000") + assert.Equal(t, "1000", result.BaseStake) + assert.Equal(t, "500", result.VestBonus) + assert.Equal(t, "200", result.RSIBonus) + assert.Equal(t, "300", result.GeneratedRewards) + assert.Equal(t, "100", result.UnclaimedRewards) + assert.Equal(t, "50", result.ClaimableCommissions) + + relayer.AssertExpectations(t) +} + +func Test_getVestingStatus_InactivePosition(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + // All zeros = no vesting position + vestingResp := encodeVestingPosition(t, + big.NewInt(0), big.NewInt(0), big.NewInt(0), + big.NewInt(0), big.NewInt(0), big.NewInt(0), big.NewInt(0), + ) + + totalRewardResp := encodeUint256(t, big.NewInt(0)) + unclaimedResp := encodeUint256(t, big.NewInt(42)) + commissionsResp := encodeUint256(t, big.NewInt(7)) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(vestingResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(totalRewardResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(unclaimedResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(commissionsResp, nil).Once() + + result, err := getVestingStatus(relayer, testAddr) + require.NoError(t, err) + + assert.False(t, result.IsActiveVestingPosition) + assert.Equal(t, "42", result.UnclaimedRewards) + assert.Equal(t, "7", result.ClaimableCommissions) + + relayer.AssertExpectations(t) +} + +func Test_getVestingStatus_VestingQueryFails(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything). + Return("", errors.New("contract reverted")).Once() + + _, err := getVestingStatus(relayer, testAddr) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to query vesting position") + + relayer.AssertExpectations(t) +} + +func Test_getVestingStatus_TotalRewardQueryFails(t *testing.T) { + t.Parallel() + + relayer := new(mockTxRelayer) + + vestingResp := encodeVestingPosition(t, + big.NewInt(0), big.NewInt(0), big.NewInt(0), + big.NewInt(0), big.NewInt(0), big.NewInt(0), big.NewInt(0), + ) + + // First call succeeds, second fails + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything).Return(vestingResp, nil).Once() + relayer.On("Call", mock.Anything, mock.Anything, mock.Anything). + Return("", errors.New("execution reverted")).Once() + + _, err := getVestingStatus(relayer, testAddr) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to query total reward") + + relayer.AssertExpectations(t) +}