From f1832c86952ba03d1450069bd967c124d4b9bed7 Mon Sep 17 00:00:00 2001 From: 0xVoronov Date: Thu, 19 Feb 2026 07:33:01 +0300 Subject: [PATCH 1/3] Implemented new delegation-info endpoint --- .../Api/TestDelegationInfoQueries.cs | 153 ++++ Mvkt.Api.Tests/Api/settings.json | 2 +- Mvkt.Api/Controllers/AccountsController.cs | 44 +- .../Models/DelegationInfo/ActualRewards.cs | 83 ++ .../DelegationInfo/CyclePaymentStatus.cs | 33 + .../DelegationInfo/CycleRewardSummary.cs | 43 + .../DelegationInfo/CycleStakingReward.cs | 23 + .../Models/DelegationInfo/DelegationInfo.cs | 33 + .../DelegationInfo/DelegationSummary.cs | 98 +++ .../Models/DelegationInfo/LastVoteInfo.cs | 28 + .../DelegationInfo/StakedValidatorInfo.cs | 18 + Mvkt.Api/Models/DelegationInfo/StakerData.cs | 23 + .../DelegationInfo/StakingRewardEvent.cs | 33 + .../Models/DelegationInfo/ValidatorInfo.cs | 18 + .../DelegationInfo/ValidatorRewardSummary.cs | 48 + .../DelegationInfo/ValidatorRewardTracking.cs | 103 +++ Mvkt.Api/Program.cs | 8 + .../OperationRepository.Transactions.cs | 22 + Mvkt.Api/Repositories/StakingRepository.cs | 37 + .../AccountDelegationInfoService.cs | 828 ++++++++++++++++++ .../Services/Delegation/DelegationConfig.cs | 16 + Mvkt.Api/appsettings.json | 9 +- 22 files changed, 1697 insertions(+), 6 deletions(-) create mode 100644 Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/ActualRewards.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/DelegationInfo.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/LastVoteInfo.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/StakerData.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs create mode 100644 Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs create mode 100644 Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs create mode 100644 Mvkt.Api/Services/Delegation/DelegationConfig.cs diff --git a/Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs b/Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs new file mode 100644 index 000000000..0ca5cf2f8 --- /dev/null +++ b/Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs @@ -0,0 +1,153 @@ +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Mvkt.Api.Tests.Api +{ + public class TestDelegationInfoQueries : IClassFixture + { + readonly SettingsFixture Settings; + readonly HttpClient Client; + + public TestDelegationInfoQueries(SettingsFixture settings) + { + Settings = settings; + Client = settings.Client; + } + + [Fact] + public async Task TestAccountEndpoint_DoesNotIncludeDelegationInfo() + { + dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}"); + + Assert.True(res is DJsonObject); + Assert.Null(res.delegationInfo); + Assert.NotNull(res.address); + Assert.NotNull(res.balance); + Assert.NotNull(res.type); + } + + [Fact] + public async Task TestDelegationInfoEndpoint_ReturnsDelegationInfo() + { + dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info"); + + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.NotNull(res.actualRewards); + } + + [Fact] + public async Task TestDelegationInfoStructure() + { + dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info"); + + Assert.True(res is DJsonObject); + + Assert.NotNull(res.summary); + Assert.NotNull(res.summary.isDelegating); + Assert.NotNull(res.summary.isStaking); + Assert.NotNull(res.summary.totalRewardsEarned); + Assert.NotNull(res.summary.rewardsByValidator); + Assert.NotNull(res.summary.rewardsByCycle); + + Assert.NotNull(res.actualRewards); + Assert.NotNull(res.actualRewards.totalActualRewards); + Assert.NotNull(res.actualRewards.delegationPayouts); + Assert.NotNull(res.actualRewards.stakingRewardsRestaked); + Assert.NotNull(res.actualRewards.expectedTotalRewards); + Assert.NotNull(res.actualRewards.pendingDelegation); + Assert.NotNull(res.actualRewards.byValidator); + } + + [Fact] + public async Task TestDelegatorAccountDelegationInfo() + { + var delegators = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegators?limit=1"); + + if (delegators is DJsonArray delegatorsArray && delegatorsArray.Count > 0) + { + dynamic delegator = delegatorsArray.First(); + var delegatorAddress = (string)delegator.address; + + dynamic res = await Client.GetJsonAsync($"/v1/accounts/{delegatorAddress}/delegation-info"); + + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.NotNull(res.actualRewards); + } + } + + [Fact] + public async Task TestNonExistentAccountDelegationInfo_Returns200WithEmptyInfo() + { + // Non-existent mv address: API returns 200 with empty delegation info (no 404) + var response = await Client.GetAsync("/v1/accounts/mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg1c/delegation-info"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + + dynamic res = await Client.GetJsonAsync("/v1/accounts/mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg1c/delegation-info"); + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.False((bool)res.summary.isDelegating); + Assert.False((bool)res.summary.isStaking); + Assert.NotNull(res.summary.stakedValidators); + Assert.True(res.summary.stakedValidators is DJsonArray); + Assert.Equal(0, ((DJsonArray)res.summary.stakedValidators).Count); + Assert.Equal(0L, (long)res.summary.totalStakedBalance); + Assert.Equal(0L, (long)res.summary.totalRewardsEarned); + Assert.NotNull(res.actualRewards); + Assert.NotNull(res.actualRewards.byValidator); + Assert.True(res.actualRewards.byValidator is DJsonArray); + Assert.Equal(0, ((DJsonArray)res.actualRewards.byValidator).Count); + } + + [Fact] + public async Task TestDelegationInfoByValidatorTracking() + { + dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info"); + + Assert.True(res is DJsonObject); + Assert.NotNull(res.actualRewards); + + var byValidator = res.actualRewards.byValidator; + if (byValidator is DJsonArray arr && arr.Count > 0) + { + dynamic first = arr.First(); + Assert.NotNull(first.address); + Assert.NotNull(first.paymentStatus); + Assert.NotNull(first.expectedDelegationRewards); + Assert.NotNull(first.actualDelegationPayouts); + Assert.NotNull(first.pendingDelegation); + } + } + + [Fact] + public async Task TestDelegationInfo_WithLegacyParameter() + { + var resLegacyTrue = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info?legacy=true"); + var resLegacyFalse = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info?legacy=false"); + + Assert.True(resLegacyTrue is DJsonObject); + Assert.True(resLegacyFalse is DJsonObject); + Assert.NotNull(resLegacyTrue.summary); + Assert.NotNull(resLegacyFalse.summary); + Assert.NotNull(resLegacyTrue.actualRewards); + Assert.NotNull(resLegacyFalse.actualRewards); + } + + [Fact] + public async Task TestContractDelegationInfo() + { + dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Originator}/delegation-info"); + + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.NotNull(res.actualRewards); + Assert.NotNull(res.summary.isDelegating); + Assert.NotNull(res.summary.isStaking); + } + } +} diff --git a/Mvkt.Api.Tests/Api/settings.json b/Mvkt.Api.Tests/Api/settings.json index 2aa0c76f0..a9eabf190 100644 --- a/Mvkt.Api.Tests/Api/settings.json +++ b/Mvkt.Api.Tests/Api/settings.json @@ -1,5 +1,5 @@ { - "url": "https://basenet.api.mavryk.network/", + "url": "http://localhost:5000/", "baker": "mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg8g", "originator": "KT1WdbBw5DXF9fXN378v8VgrPqTsCKu2BPgD", "delegator": "mv1LMue3zJSujtVeEQneaK7VZAeg8WSF3w5y", diff --git a/Mvkt.Api/Controllers/AccountsController.cs b/Mvkt.Api/Controllers/AccountsController.cs index 90b77e725..690c46a5d 100644 --- a/Mvkt.Api/Controllers/AccountsController.cs +++ b/Mvkt.Api/Controllers/AccountsController.cs @@ -1,10 +1,11 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Mvkt.Api.Models; using Mvkt.Api.Repositories; using Mvkt.Api.Services; using Mvkt.Api.Services.Cache; +using Mvkt.Api.Services.Delegation; namespace Mvkt.Api.Controllers { @@ -17,14 +18,22 @@ public class AccountsController : ControllerBase readonly ReportRepository Reports; readonly StateCache State; readonly ResponseCacheService ResponseCache; - - public AccountsController(AccountRepository accounts, BalanceHistoryRepository history, ReportRepository reports, StateCache state, ResponseCacheService responseCache) + readonly AccountDelegationInfoService DelegationInfoService; + + public AccountsController( + AccountRepository accounts, + BalanceHistoryRepository history, + ReportRepository reports, + StateCache state, + ResponseCacheService responseCache, + AccountDelegationInfoService delegationInfoService) { Accounts = accounts; History = history; Reports = reports; State = state; ResponseCache = responseCache; + DelegationInfoService = delegationInfoService; } /// @@ -175,7 +184,7 @@ public async Task> GetByAddress( [Required][Address] string address, bool legacy = true) { - var query = ResponseCacheService.BuildKey(Request.Path.Value, ("legacy", legacy)); + var query = ResponseCacheService.BuildKey(Request.Path.Value, ("legacy", legacy)); if (ResponseCache.TryGet(query, out var cached)) return this.Bytes(cached); @@ -185,6 +194,33 @@ public async Task> GetByAddress( return this.Bytes(cached); } + /// + /// Get delegation and staking information for an account + /// + /// + /// Returns comprehensive delegation and staking information for the specified account. + /// Consolidates data from multiple sources: account status, expected rewards from cycle data, + /// actual rewards (transactions and restake events), payment status per validator and per cycle. + /// + /// Account address + /// If `true` (by default), the account is resolved using legacy semantics. This is a part of a deprecation mechanism, allowing smooth migration. + /// + [HttpGet("{address}/delegation-info")] + public async Task> GetDelegationInfo( + [Required][Address] string address, + bool legacy = true) + { + var query = ResponseCacheService.BuildKey(Request.Path.Value, ("legacy", legacy)); + + if (ResponseCache.TryGet(query, out var cached)) + return this.Bytes(cached); + + var res = await DelegationInfoService.GetDelegationInfoAsync(address, legacy); + cached = ResponseCache.Set(query, res); + + return this.Bytes(cached); + } + /// /// Get account contracts /// diff --git a/Mvkt.Api/Models/DelegationInfo/ActualRewards.cs b/Mvkt.Api/Models/DelegationInfo/ActualRewards.cs new file mode 100644 index 000000000..06aef7d43 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/ActualRewards.cs @@ -0,0 +1,83 @@ +namespace Mvkt.Api.Models +{ + /// + /// Actual rewards received compared to expected rewards + /// + public class ActualRewards + { + /// + /// Total actual rewards received (micro tez) + /// + public long TotalActualRewards { get; set; } + + /// + /// Delegation payouts received (micro tez) + /// + public long DelegationPayouts { get; set; } + + /// + /// Number of delegation payout transactions + /// + public int DelegationPayoutCount { get; set; } + + /// + /// Staking rewards auto-restaked (micro tez) + /// + public long StakingRewardsRestaked { get; set; } + + /// + /// Number of staking restake events + /// + public int StakingRestakeCount { get; set; } + + /// + /// Expected delegation rewards (micro tez) + /// + public long ExpectedDelegationRewards { get; set; } + + /// + /// Expected staking rewards (micro tez) + /// + public long ExpectedStakingRewards { get; set; } + + /// + /// Expected total rewards (micro tez) + /// + public long ExpectedTotalRewards { get; set; } + + /// + /// Pending delegation rewards not yet paid (micro tez) + /// + public long PendingDelegation { get; set; } + + /// + /// Pending staking rewards (micro tez) + /// + public long PendingStaking { get; set; } + + /// + /// Total pending rewards (micro tez) + /// + public long PendingTotal { get; set; } + + /// + /// Reward tracking per validator + /// + public List ByValidator { get; set; } + + /// + /// All staking reward events + /// + public List AllStakingRewardEvents { get; set; } + + /// + /// First reward date (ISO 8601) + /// + public DateTime? FirstRewardDate { get; set; } + + /// + /// Last reward date (ISO 8601) + /// + public DateTime? LastRewardDate { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs b/Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs new file mode 100644 index 000000000..b1439fdb7 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs @@ -0,0 +1,33 @@ +namespace Mvkt.Api.Models +{ + /// + /// Payment status for a specific cycle + /// + public class CyclePaymentStatus + { + /// + /// Cycle number + /// + public int Cycle { get; set; } + + /// + /// Expected reward for this cycle (micro tez) + /// + public long ExpectedReward { get; set; } + + /// + /// Payment status: paid, underpaid, overpaid, pending + /// + public string Status { get; set; } + + /// + /// Validator address + /// + public string ValidatorAddress { get; set; } + + /// + /// Validator alias + /// + public string ValidatorAlias { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs b/Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs new file mode 100644 index 000000000..677fa02e4 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs @@ -0,0 +1,43 @@ +namespace Mvkt.Api.Models +{ + /// + /// Summary of rewards for a specific cycle + /// + public class CycleRewardSummary + { + /// + /// Cycle number + /// + public int Cycle { get; set; } + + /// + /// Total rewards for this cycle (micro tez) + /// + public long Rewards { get; set; } + + /// + /// Delegated balance during this cycle (micro tez) + /// + public long DelegatedBalance { get; set; } + + /// + /// Staked balance during this cycle (micro tez) + /// + public long StakedBalance { get; set; } + + /// + /// Validator for this cycle + /// + public ValidatorInfo Validator { get; set; } + + /// + /// Staking rewards for this cycle (micro tez) + /// + public long StakingRewards { get; set; } + + /// + /// Delegation rewards for this cycle (micro tez) + /// + public long DelegationRewards { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs b/Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs new file mode 100644 index 000000000..1a2a7fb58 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs @@ -0,0 +1,23 @@ +namespace Mvkt.Api.Models +{ + /// + /// Staking reward for a specific cycle + /// + public class CycleStakingReward + { + /// + /// Cycle number + /// + public int Cycle { get; set; } + + /// + /// Reward amount (micro tez) + /// + public long Amount { get; set; } + + /// + /// Timestamp of the reward (ISO 8601) + /// + public DateTime? Timestamp { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/DelegationInfo.cs b/Mvkt.Api/Models/DelegationInfo/DelegationInfo.cs new file mode 100644 index 000000000..5bc99c7b6 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/DelegationInfo.cs @@ -0,0 +1,33 @@ +namespace Mvkt.Api.Models +{ + /// + /// Complete delegation and staking information for an account + /// + public class DelegationInfo + { + /// + /// Summary of delegation and staking status + /// + public DelegationSummary Summary { get; set; } + + /// + /// Actual rewards received vs expected + /// + public ActualRewards ActualRewards { get; set; } + + /// + /// Estimated date of next payout (if predictable) + /// + public DateTime? EstimatedNextPayout { get; set; } + + /// + /// Average payout interval in days + /// + public double? AvgPayoutIntervalDays { get; set; } + + /// + /// Last vote information (optional) + /// + public LastVoteInfo LastVote { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs b/Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs new file mode 100644 index 000000000..1f6bd5cb6 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs @@ -0,0 +1,98 @@ +namespace Mvkt.Api.Models +{ + /// + /// Summary of account's delegation and staking status + /// + public class DelegationSummary + { + /// + /// Whether the account is currently delegating + /// + public bool IsDelegating { get; set; } + + /// + /// Current delegated validator + /// + public ValidatorInfo DelegatedValidator { get; set; } + + /// + /// Amount delegated (micro tez) + /// + public long DelegatedBalance { get; set; } + + /// + /// Delegation start time (ISO 8601) + /// + public DateTime? DelegationTime { get; set; } + + /// + /// Whether the account is currently staking + /// + public bool IsStaking { get; set; } + + /// + /// List of validators the account is staked with + /// + public List StakedValidators { get; set; } + + /// + /// Total amount staked across all validators (micro tez) + /// + public long TotalStakedBalance { get; set; } + + /// + /// Total rewards earned (delegation + staking) (micro tez) + /// + public long TotalRewardsEarned { get; set; } + + /// + /// Rewards breakdown by validator + /// + public List RewardsByValidator { get; set; } + + /// + /// Rewards breakdown by cycle + /// + public List RewardsByCycle { get; set; } + + /// + /// Percentage of balance that is staked + /// + public double StakingPercentage { get; set; } + + /// + /// Percentage of balance that is delegated + /// + public double DelegationPercentage { get; set; } + + /// + /// Total number of validators used (past + current) + /// + public int TotalValidatorsUsed { get; set; } + + /// + /// Number of currently active validators + /// + public int CurrentValidatorCount { get; set; } + + /// + /// Number of past validators no longer used + /// + public int PastValidatorCount { get; set; } + + /// + /// First cycle with rewards + /// + public int? FirstRewardCycle { get; set; } + + /// + /// Last cycle with rewards + /// + public int? LastRewardCycle { get; set; } + + /// + /// Total cycles that earned rewards + /// + public int TotalCyclesWithRewards { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/LastVoteInfo.cs b/Mvkt.Api/Models/DelegationInfo/LastVoteInfo.cs new file mode 100644 index 000000000..983e21717 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/LastVoteInfo.cs @@ -0,0 +1,28 @@ +namespace Mvkt.Api.Models +{ + /// + /// Information about the last vote cast by the account + /// + public class LastVoteInfo + { + /// + /// Proposal hash + /// + public string Proposal { get; set; } + + /// + /// Vote: yay, nay, pass + /// + public string Vote { get; set; } + + /// + /// Voting period + /// + public int Period { get; set; } + + /// + /// Timestamp of the vote (ISO 8601) + /// + public DateTime Timestamp { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs b/Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs new file mode 100644 index 000000000..6ebc91601 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs @@ -0,0 +1,18 @@ +namespace Mvkt.Api.Models +{ + /// + /// Information about a validator the account is staked with + /// + public class StakedValidatorInfo + { + /// + /// Validator information + /// + public ValidatorInfo Baker { get; set; } + + /// + /// Amount staked with this validator (micro tez) + /// + public long StakedBalance { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/StakerData.cs b/Mvkt.Api/Models/DelegationInfo/StakerData.cs new file mode 100644 index 000000000..923d72835 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/StakerData.cs @@ -0,0 +1,23 @@ +namespace Mvkt.Api.Models +{ + /// + /// Staker's balance at a specific baker (validator). Used in delegation info. + /// + public class StakerData + { + /// + /// Baker (validator) address + /// + public string BakerAddress { get; set; } + + /// + /// Baker alias (off-chain name) + /// + public string BakerAlias { get; set; } + + /// + /// Staked balance (mutez) at this baker + /// + public long StakedBalance { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs b/Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs new file mode 100644 index 000000000..8f99745e2 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs @@ -0,0 +1,33 @@ +namespace Mvkt.Api.Models +{ + /// + /// A single staking reward event + /// + public class StakingRewardEvent + { + /// + /// Cycle when the reward was received + /// + public int Cycle { get; set; } + + /// + /// Reward amount (micro tez) + /// + public long Amount { get; set; } + + /// + /// Timestamp of the reward (ISO 8601) + /// + public DateTime? Timestamp { get; set; } + + /// + /// Validator address + /// + public string ValidatorAddress { get; set; } + + /// + /// Validator alias + /// + public string ValidatorAlias { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs b/Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs new file mode 100644 index 000000000..3fde7857a --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs @@ -0,0 +1,18 @@ +namespace Mvkt.Api.Models +{ + /// + /// Basic validator information + /// + public class ValidatorInfo + { + /// + /// Validator address + /// + public string Address { get; set; } + + /// + /// Validator alias/name + /// + public string Alias { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs b/Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs new file mode 100644 index 000000000..32bfcc252 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs @@ -0,0 +1,48 @@ +namespace Mvkt.Api.Models +{ + /// + /// Summary of rewards from a specific validator + /// + public class ValidatorRewardSummary + { + /// + /// Validator information + /// + public ValidatorInfo Validator { get; set; } + + /// + /// Total rewards from this validator (micro tez) + /// + public long TotalRewards { get; set; } + + /// + /// Number of cycles with rewards + /// + public int CycleCount { get; set; } + + /// + /// First cycle with rewards + /// + public int FirstCycle { get; set; } + + /// + /// Last cycle with rewards + /// + public int LastCycle { get; set; } + + /// + /// Total staking rewards (auto-restaked) (micro tez) + /// + public long TotalStakingRewards { get; set; } + + /// + /// Total delegation rewards (paid out) (micro tez) + /// + public long TotalDelegationRewards { get; set; } + + /// + /// Whether this is a current validator + /// + public bool IsCurrentValidator { get; set; } + } +} diff --git a/Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs b/Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs new file mode 100644 index 000000000..e6af610b9 --- /dev/null +++ b/Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs @@ -0,0 +1,103 @@ +namespace Mvkt.Api.Models +{ + /// + /// Detailed reward tracking for a specific validator + /// + public class ValidatorRewardTracking + { + /// + /// Validator address + /// + public string Address { get; set; } + + /// + /// Validator alias + /// + public string Alias { get; set; } + + /// + /// Expected staking rewards (micro tez) + /// + public long ExpectedStakingRewards { get; set; } + + /// + /// Expected delegation rewards (micro tez) + /// + public long ExpectedDelegationRewards { get; set; } + + /// + /// Expected total rewards (micro tez) + /// + public long ExpectedTotalGross { get; set; } + + /// + /// Actual delegation payouts received (micro tez) + /// + public long ActualDelegationPayouts { get; set; } + + /// + /// Actual staking rewards restaked (micro tez) + /// + public long ActualStakingRestaked { get; set; } + + /// + /// Actual total received (micro tez) + /// + public long ActualTotalReceived { get; set; } + + /// + /// Pending delegation rewards (micro tez) + /// + public long PendingDelegation { get; set; } + + /// + /// Pending staking rewards (micro tez) + /// + public long PendingStaking { get; set; } + + /// + /// Total pending rewards (micro tez) + /// + public long PendingTotal { get; set; } + + /// + /// Payment status: fully_paid, underpaid, overpaid, pending + /// + public string PaymentStatus { get; set; } + + /// + /// Payment percentage (actual / expected * 100) + /// + public double PaymentPercentage { get; set; } + + /// + /// Number of cycles with rewards + /// + public int CycleCount { get; set; } + + /// + /// First cycle with rewards + /// + public int FirstCycle { get; set; } + + /// + /// Last cycle with rewards + /// + public int LastCycle { get; set; } + + /// + /// Staking rewards by cycle + /// + public List StakingRewardsByCycle { get; set; } + + /// + /// Number of staking restake events + /// + public int StakingRestakeEventCount { get; set; } + + /// + /// Delegation payment status by cycle + /// + public List DelegationByCycle { get; set; } + } +} diff --git a/Mvkt.Api/Program.cs b/Mvkt.Api/Program.cs index a1e594673..a9d467e0c 100644 --- a/Mvkt.Api/Program.cs +++ b/Mvkt.Api/Program.cs @@ -11,6 +11,7 @@ using Mvkt.Api.Repositories; using Mvkt.Api.Services; using Mvkt.Api.Services.Auth; +using Mvkt.Api.Services.Delegation; using Mvkt.Api.Services.Cache; using Mvkt.Api.Services.Sync; using Mvkt.Api.Swagger; @@ -81,6 +82,13 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddSingleton(serviceProvider => +{ + var config = serviceProvider.GetRequiredService(); + return config.GetSection("Delegation").Get() ?? new DelegationConfig(); +}); +builder.Services.AddTransient(); + builder.Services.AddAuthService(builder.Configuration); builder.Services.AddSingleton(); diff --git a/Mvkt.Api/Repositories/OperationRepository.Transactions.cs b/Mvkt.Api/Repositories/OperationRepository.Transactions.cs index c2680ca69..d9bb44eab 100644 --- a/Mvkt.Api/Repositories/OperationRepository.Transactions.cs +++ b/Mvkt.Api/Repositories/OperationRepository.Transactions.cs @@ -41,6 +41,28 @@ public async Task GetTransactionsCount( return await db.QueryFirstAsync(sql.Query, sql.Params); } + /// + /// Get delegation payout aggregates: sum of amounts and count per sender, for transactions to target from given senders (e.g. validators). Status = applied. + /// Returns (SenderAddress, SenderAlias, Amount, Count, MinLevel, MaxLevel) per sender; address/alias from Accounts JOIN. + /// + public async Task> GetDelegationPayoutsToAccountAsync(int targetAccountId, IReadOnlyList senderIds) + { + if (senderIds == null || senderIds.Count == 0) + return Array.Empty<(string, string, long, int, int, int)>(); + + const string sql = @" + SELECT acc.""Address"" AS ""SenderAddress"", acc.""Extras""#>>'{profile,alias}' AS ""SenderAlias"", + SUM(o.""Amount"")::bigint AS ""Amount"", COUNT(*)::int AS ""Count"", + MIN(o.""Level"")::int AS ""MinLevel"", MAX(o.""Level"")::int AS ""MaxLevel"" + FROM ""TransactionOps"" o + INNER JOIN ""Accounts"" acc ON acc.""Id"" = o.""SenderId"" + WHERE o.""TargetId"" = @targetAccountId AND o.""SenderId"" = ANY(@senderIds) AND o.""Status"" = 1 + GROUP BY o.""SenderId"", acc.""Address"", acc.""Extras"""; + await using var db = await DataSource.OpenConnectionAsync(); + var rows = await db.QueryAsync<(string, string, long, int, int, int)>(sql, new { targetAccountId, senderIds = senderIds.ToArray() }); + return rows.ToList(); + } + public async Task> GetTransactions(string hash, MichelineFormat format, Symbols quote) { var sql = $@" diff --git a/Mvkt.Api/Repositories/StakingRepository.cs b/Mvkt.Api/Repositories/StakingRepository.cs index 7544cfe53..b7577803d 100644 --- a/Mvkt.Api/Repositories/StakingRepository.cs +++ b/Mvkt.Api/Repositories/StakingRepository.cs @@ -87,6 +87,43 @@ async Task> QueryStakingUpdatesAsync(StakingUpdateFilter fi return await db.QueryAsync(sql.Query, sql.Params); } + /// + /// Get current staked balance per baker for a staker (aggregated from StakingUpdates in one query). + /// Type: Stake=0 +Restake=2 add to balance, Unstake=1 +SlashStaked=4 subtract; Finalize/SlashUnstaked do not change staked. + /// Returns baker address and alias from Accounts in the same query (no cache lookups). + /// + public async Task> GetStakerBalancesByBakerAsync(int stakerId) + { + const string sql = @" + WITH agg AS ( + SELECT su.""BakerId"", + SUM(CASE su.""Type"" WHEN 0 THEN su.""Amount"" WHEN 2 THEN su.""Amount"" WHEN 1 THEN -su.""Amount"" WHEN 4 THEN -su.""Amount"" ELSE 0 END)::bigint AS ""StakedBalance"" + FROM ""StakingUpdates"" su + WHERE su.""StakerId"" = @stakerId + GROUP BY su.""BakerId"" + ) + SELECT baker.""Address"" AS ""BakerAddress"", baker.""Extras""#>>'{profile,alias}' AS ""BakerAlias"", agg.""StakedBalance"" + FROM agg + INNER JOIN ""Accounts"" baker ON baker.""Id"" = agg.""BakerId"" + WHERE agg.""StakedBalance"" > 0"; + await using var db = await DataSource.OpenConnectionAsync(); + return await db.QueryAsync(sql, new { stakerId }); + } + + /// + /// Get levels of restake events for a staker (for reward date range). Type 2 = Restake. + /// + public async Task> GetStakerRestakeLevelsAsync(int stakerId) + { + const string sql = @" + SELECT ""Level"" + FROM ""StakingUpdates"" + WHERE ""StakerId"" = @stakerId AND ""Type"" = 2 + ORDER BY ""Id"""; + await using var db = await DataSource.OpenConnectionAsync(); + return await db.QueryAsync(sql, new { stakerId }); + } + public async Task GetStakingUpdatesCount(StakingUpdateFilter filter) { var sql = new SqlBuilder(@" diff --git a/Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs b/Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs new file mode 100644 index 000000000..4260c0ff2 --- /dev/null +++ b/Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs @@ -0,0 +1,828 @@ +using Microsoft.Extensions.Logging; +using Mvkt.Api.Models; +using Mvkt.Api.Repositories; +using Mvkt.Api.Services; +using Mvkt.Api.Services.Cache; + +namespace Mvkt.Api.Services.Delegation +{ + /// + /// Service for building comprehensive delegation and staking information for an account + /// Consolidates data from multiple endpoints (account, rewards, staking, transactions, staking updates) + /// + public class AccountDelegationInfoService + { + readonly AccountRepository Accounts; + readonly RewardsRepository Rewards; + readonly StakingRepository Staking; + readonly OperationRepository Operations; + readonly VotingRepository Voting; + readonly ILogger Logger; + readonly AccountsCache AccountsCache; + readonly ProtocolsCache Protocols; + readonly TimeCache Times; + readonly DelegationConfig DelegationConfig; + + public AccountDelegationInfoService( + AccountRepository accounts, + RewardsRepository rewards, + StakingRepository staking, + OperationRepository operations, + VotingRepository voting, + ILogger logger, + AccountsCache accountsCache, + ProtocolsCache protocols, + TimeCache times, + DelegationConfig delegationConfig) + { + Accounts = accounts; + Rewards = rewards; + Staking = staking; + Operations = operations; + Voting = voting; + Logger = logger; + AccountsCache = accountsCache; + Protocols = protocols; + Times = times; + DelegationConfig = delegationConfig ?? new DelegationConfig(); + } + + /// + /// Get complete delegation info for an account + /// + public async Task GetDelegationInfoAsync(string address, bool legacy = true) + { + Account account; + try + { + account = await Accounts.Get(address, legacy); + } + catch (Exception ex) when (ex.Message?.Contains("Invalid raw account type") == true) + { + return BuildEmptyDelegationInfo(); + } + + if (account == null || account is EmptyAccount) + return BuildEmptyDelegationInfo(); + + int accountId = 0; + bool isDelegating = false; + ValidatorInfo delegatedValidator = null; + long delegatedBalance = 0; + DateTime? delegationTime = null; + long stakedBalance = 0; + long accountBalance = 0; + + if (account is User user) + { + accountId = user.Id; + isDelegating = user.Delegate != null; + delegatedValidator = user.Delegate != null + ? new ValidatorInfo { Address = user.Delegate.Address, Alias = user.Delegate.Alias } + : null; + delegatedBalance = user.Balance; + delegationTime = user.DelegationTime; + stakedBalance = user.StakedBalance; + accountBalance = user.Balance; + } + else if (account is Models.Delegate delegateAccount) + { + accountId = delegateAccount.Id; + stakedBalance = delegateAccount.StakedBalance; + accountBalance = delegateAccount.Balance; + isDelegating = false; + } + else if (account is Contract contract) + { + accountId = contract.Id; + isDelegating = contract.Delegate != null; + delegatedValidator = contract.Delegate != null + ? new ValidatorInfo { Address = contract.Delegate.Address, Alias = contract.Delegate.Alias } + : null; + delegatedBalance = contract.Balance; + delegationTime = contract.DelegationTime; + accountBalance = contract.Balance; + } + else + { + // Rollup, SmartRollup, Ghost — no delegation/staking in model + accountId = 0; + accountBalance = 0; + } + + if (accountId == 0) + return BuildEmptyDelegationInfo(); + + // Optimized + var stakingInfo = await GetStakingInfoAsync(accountId); + + // Optimized + var delegatorRewards = await GetDelegatorRewardsAsync(address); + + var actualRewards = await CalculateActualRewardsAsync(accountId, delegatorRewards, stakingInfo); + + var summary = BuildDelegationSummary( + account, + isDelegating, + delegatedValidator, + delegatedBalance, + delegationTime, + stakingInfo, + delegatorRewards, + actualRewards, + accountBalance); + + var (estimatedNextPayout, avgPayoutInterval) = EstimateNextPayout(actualRewards); + + var lastVote = await GetLastVoteAsync(accountId); + + return new DelegationInfo + { + Summary = summary, + ActualRewards = actualRewards, + EstimatedNextPayout = estimatedNextPayout, + AvgPayoutIntervalDays = avgPayoutInterval, + LastVote = lastVote + }; + } + + /// + /// Get staking information for an account (by internal id). Call only when accountId is known to exist. + /// Uses aggregated query (staked balance, baker address and alias in one DB round-trip). + /// + private async Task> GetStakingInfoAsync(int accountId) + { + try + { + var list = await Staking.GetStakerBalancesByBakerAsync(accountId); + return list.ToList(); + } + catch + { + return new List(); + } + } + + /// + /// Get delegator rewards for an address + /// + private async Task> GetDelegatorRewardsAsync(string address) + { + try + { + var rewards = await Rewards.GetDelegatorRewards( + address, + cycle: null, + sort: new SortParameter { Asc = "cycle" }, + offset: new OffsetParameter { El = 0 }, + limit: 10000, + quote: Symbols.None + ); + + return rewards.ToList(); + } + catch + { + return new List(); + } + } + + /// + /// Calculate actual rewards from delegation payout aggregates and staking restake events. + /// Loads payouts and restake levels from DB, then aggregates expected/actual per validator and returns ActualRewards. + /// + private async Task CalculateActualRewardsAsync( + int accountId, + List delegatorRewards, + List stakingInfo) + { + var validatorAddresses = delegatorRewards + .Select(r => r.Baker?.Address) + .Where(a => a != null) + .ToHashSet(); + var validatorIds = validatorAddresses + .Select(addr => AccountsCache.Get(addr)) + .Where(acc => acc != null) + .Select(acc => acc.Id) + .ToList(); + + var payoutRows = (await Operations.GetDelegationPayoutsToAccountAsync(accountId, validatorIds)).ToList(); + + // Include payouts from configured alternative payout addresses (validator's payout wallet) + var payoutAddressToValidator = new Dictionary(StringComparer.OrdinalIgnoreCase); + var payoutAccountIds = new List(); + if (DelegationConfig.ValidatorPayoutAddresses != null) + { + foreach (var validatorAddress in validatorAddresses) + { + if (string.IsNullOrEmpty(validatorAddress)) continue; + if (!DelegationConfig.ValidatorPayoutAddresses.TryGetValue(validatorAddress, out var payoutAddresses) || payoutAddresses == null) + continue; + foreach (var payoutAddr in payoutAddresses) + { + if (string.IsNullOrEmpty(payoutAddr)) continue; + var acc = AccountsCache.Get(payoutAddr); + if (acc != null) + { + payoutAddressToValidator[payoutAddr] = validatorAddress; + payoutAccountIds.Add(acc.Id); + } + } + } + } + + if (payoutAccountIds.Count > 0) + { + var payoutWalletRows = await Operations.GetDelegationPayoutsToAccountAsync(accountId, payoutAccountIds); + foreach (var row in payoutWalletRows) + { + var senderAddr = row.SenderAddress ?? ""; + if (!payoutAddressToValidator.TryGetValue(senderAddr, out var validatorAddr)) continue; + var validatorAlias = AccountsCache.Get(validatorAddr)?.Alias ?? row.SenderAlias; + payoutRows.Add((validatorAddr, validatorAlias, row.Amount, row.Count, row.MinLevel, row.MaxLevel)); + } + } + + var delegationPayoutsByValidator = payoutRows + .GroupBy(r => r.SenderAddress ?? "") + .ToDictionary(g => g.Key, g => + { + var first = g.First(); + return new ValidatorPayoutData + { + Amount = g.Sum(r => r.Amount), + Count = g.Sum(r => r.Count), + Alias = first.SenderAlias + }; + }); + var delegationPayouts = payoutRows.Sum(r => r.Amount); + var delegationPayoutCount = payoutRows.Sum(r => r.Count); + var minPayoutLevel = payoutRows.Count > 0 ? payoutRows.Min(r => r.MinLevel) : (int?)null; + var maxPayoutLevel = payoutRows.Count > 0 ? payoutRows.Max(r => r.MaxLevel) : (int?)null; + + var restakeLevels = await Staking.GetStakerRestakeLevelsAsync(accountId); + var restakeTimestamps = restakeLevels.Select(l => Times[l]).ToList(); + var allDates = new List(restakeTimestamps); + if (minPayoutLevel.HasValue) allDates.Add(Times[minPayoutLevel.Value]); + if (maxPayoutLevel.HasValue && maxPayoutLevel != minPayoutLevel) allDates.Add(Times[maxPayoutLevel.Value]); + allDates.Sort(); + + var aggregated = AggregateRewardsFromDelegatorRewards(delegatorRewards); + var allStakingRewardEvents = aggregated.AllStakingRewardEvents.OrderByDescending(e => e.Cycle).ToList(); + + var allValidators = new HashSet( + aggregated.ExpectedByValidator.Keys + .Concat(delegationPayoutsByValidator.Keys) + .Concat(aggregated.StakingByValidator.Keys)); + var byValidator = new List(allValidators.Count); + + foreach (var address in allValidators) + { + aggregated.ExpectedByValidator.TryGetValue(address, out var cycleInfo); + delegationPayoutsByValidator.TryGetValue(address, out var actualDelegation); + aggregated.StakingByValidator.TryGetValue(address, out var actualStaking); + + var actualDelegationPayouts = actualDelegation?.Amount ?? 0; + var actualStakingRestaked = actualStaking?.Amount ?? 0; + var actualTotalReceived = actualDelegationPayouts + actualStakingRestaked; + var expectedDelegationRewards = cycleInfo?.DelegationRewards ?? 0; + var expectedStakingRewards = actualStakingRestaked; + var expectedTotalGross = expectedStakingRewards + expectedDelegationRewards; + var pendingDelegation = Math.Max(0, expectedDelegationRewards - actualDelegationPayouts); + var pendingTotal = pendingDelegation + 0L; + + var (paymentStatus, paymentPercentage) = GetPaymentStatus( + actualDelegationPayouts, expectedDelegationRewards, actualStakingRestaked); + + var cycles = cycleInfo?.Cycles ?? new List(); + var alias = cycleInfo?.Alias ?? actualDelegation?.Alias ?? actualStaking?.Alias; + var stakingByCycle = actualStaking?.ByCycle ?? new List(); + var delegationCycles = cycleInfo?.DelegationByCycle ?? new List(); + var sortedDelegationCycles = delegationCycles.Count > 0 + ? delegationCycles.OrderBy(d => d.Cycle).ToList() + : delegationCycles; + var delegationByCycleStatus = BuildDelegationByCycleStatus( + sortedDelegationCycles, actualDelegationPayouts, address, alias); + + var sortedStakingByCycle = stakingByCycle.Count > 0 + ? stakingByCycle.OrderByDescending(s => s.Cycle).ToList() + : stakingByCycle; + + byValidator.Add(new ValidatorRewardTracking + { + Address = address, + Alias = alias, + ExpectedStakingRewards = expectedStakingRewards, + ExpectedDelegationRewards = expectedDelegationRewards, + ExpectedTotalGross = expectedTotalGross, + ActualDelegationPayouts = actualDelegationPayouts, + ActualStakingRestaked = actualStakingRestaked, + ActualTotalReceived = actualTotalReceived, + PendingDelegation = pendingDelegation, + PendingStaking = 0L, + PendingTotal = pendingTotal, + PaymentStatus = paymentStatus, + PaymentPercentage = paymentPercentage, + CycleCount = cycles.Count, + FirstCycle = cycles.Count > 0 ? cycles.Min() : 0, + LastCycle = cycles.Count > 0 ? cycles.Max() : 0, + StakingRewardsByCycle = sortedStakingByCycle, + StakingRestakeEventCount = actualStaking?.Count ?? 0, + DelegationByCycle = delegationByCycleStatus + }); + } + + byValidator.Sort((a, b) => b.ActualTotalReceived.CompareTo(a.ActualTotalReceived)); + + DateTime? firstRewardDate; + DateTime? lastRewardDate; + if (allDates.Count > 0) + { + firstRewardDate = allDates[0]; + lastRewardDate = allDates[allDates.Count - 1]; + } + else + { + firstRewardDate = null; + lastRewardDate = null; + } + + long totalExpectedDelegation = aggregated.ExpectedByValidator.Values.Sum(v => v.DelegationRewards); + long totalActualRewards = delegationPayouts + aggregated.StakingRestakedTotal; + long expectedTotalRewards = aggregated.StakingRestakedTotal + totalExpectedDelegation; + long totalPendingDelegation = Math.Max(0, totalExpectedDelegation - delegationPayouts); + + return new ActualRewards + { + TotalActualRewards = totalActualRewards, + DelegationPayouts = delegationPayouts, + DelegationPayoutCount = delegationPayoutCount, + StakingRewardsRestaked = aggregated.StakingRestakedTotal, + StakingRestakeCount = aggregated.StakingRestakeCount, + ExpectedDelegationRewards = totalExpectedDelegation, + ExpectedStakingRewards = aggregated.StakingRestakedTotal, + ExpectedTotalRewards = expectedTotalRewards, + PendingDelegation = totalPendingDelegation, + PendingStaking = 0, + PendingTotal = totalPendingDelegation, + ByValidator = byValidator, + AllStakingRewardEvents = allStakingRewardEvents, + FirstRewardDate = firstRewardDate, + LastRewardDate = lastRewardDate + }; + } + + /// + /// Aggregates staking rewards and expected (delegation + staking) rewards per validator from per-cycle delegator rewards. + /// + private static AggregatedRewardsFromDelegators AggregateRewardsFromDelegatorRewards(List delegatorRewards) + { + var stakingRewardsByValidator = new Dictionary(); + long stakingRewardsRestaked = 0; + int stakingRestakeCount = 0; + var allStakingRewardEvents = new List(); + var expectedByValidator = new Dictionary(); + + foreach (var reward in delegatorRewards) + { + var bakerAddress = reward.Baker?.Address ?? ""; + if (string.IsNullOrEmpty(bakerAddress)) continue; + + var bakerTotalSharedRewards = reward.BlockRewardsStakedShared + reward.EndorsementRewardsStakedShared; + var userStakedBalance = reward.StakedBalance; + var externalStakedBalance = reward.ExternalStakedBalance; + + long stakingReward = 0; + if (externalStakedBalance > 0 && userStakedBalance > 0) + stakingReward = (long)((double)userStakedBalance / externalStakedBalance * bakerTotalSharedRewards); + + if (stakingReward > 0) + { + if (!stakingRewardsByValidator.TryGetValue(bakerAddress, out var stakingData)) + { + stakingData = new ValidatorStakingData + { + Amount = 0, + Count = 0, + Alias = reward.Baker?.Name, + ByCycle = new List() + }; + stakingRewardsByValidator[bakerAddress] = stakingData; + } + stakingData.Amount += stakingReward; + stakingData.Count += 1; + stakingData.ByCycle.Add(new CycleStakingReward { Cycle = reward.Cycle, Amount = stakingReward, Timestamp = null }); + stakingRewardsRestaked += stakingReward; + stakingRestakeCount += 1; + allStakingRewardEvents.Add(new StakingRewardEvent + { + Cycle = reward.Cycle, + Amount = stakingReward, + Timestamp = null, + ValidatorAddress = bakerAddress, + ValidatorAlias = reward.Baker?.Name + }); + } + + var validatorDelegationRewards = reward.BlockRewardsDelegated + reward.EndorsementRewardsDelegated; + var userDelegatedBalance = reward.DelegatedBalance; + var validatorStakingBalance = reward.StakingBalance; + long userDelegationReward = 0; + if (validatorStakingBalance > 0 && userDelegatedBalance > 0) + userDelegationReward = (long)((double)userDelegatedBalance / validatorStakingBalance * validatorDelegationRewards); + long userStakingReward = 0; + if (externalStakedBalance > 0 && userStakedBalance > 0) + userStakingReward = (long)((double)userStakedBalance / externalStakedBalance * bakerTotalSharedRewards); + + if (!expectedByValidator.TryGetValue(bakerAddress, out var expectedData)) + { + expectedData = new ValidatorExpectedData + { + StakingRewards = 0, + DelegationRewards = 0, + Alias = reward.Baker?.Name, + Cycles = new List(), + DelegationByCycle = new List() + }; + expectedByValidator[bakerAddress] = expectedData; + } + expectedData.DelegationRewards += userDelegationReward; + expectedData.StakingRewards += userStakingReward; + expectedData.Cycles.Add(reward.Cycle); + if (userDelegationReward > 0) + expectedData.DelegationByCycle.Add(new CycleDelegationData { Cycle = reward.Cycle, Amount = userDelegationReward }); + } + + return new AggregatedRewardsFromDelegators + { + StakingByValidator = stakingRewardsByValidator, + StakingRestakedTotal = stakingRewardsRestaked, + StakingRestakeCount = stakingRestakeCount, + AllStakingRewardEvents = allStakingRewardEvents, + ExpectedByValidator = expectedByValidator + }; + } + + /// + /// Estimate next payout date and average interval in days based on aggregated rewards timeline. + /// Uses first/last reward dates and total count of payout + restake events. + /// + private (DateTime?, double?) EstimateNextPayout(ActualRewards actualRewards) + { + if (actualRewards == null) + { + Logger.LogInformation("EstimateNextPayout: actualRewards is null"); + return (null, null); + } + + var first = actualRewards.FirstRewardDate; + var last = actualRewards.LastRewardDate; + if (!first.HasValue || !last.HasValue) + { + Logger.LogInformation( + "EstimateNextPayout: missing reward dates. First={First}, Last={Last}", + first, last); + return (null, null); + } + + var eventCount = actualRewards.DelegationPayoutCount + actualRewards.StakingRestakeCount; + if (eventCount < 2) + { + Logger.LogInformation( + "EstimateNextPayout: insufficient events. DelegationPayoutCount={DelegationPayoutCount}, StakingRestakeCount={StakingRestakeCount}", + actualRewards.DelegationPayoutCount, + actualRewards.StakingRestakeCount); + return (null, null); + } + + var totalDays = (last.Value - first.Value).TotalDays; + if (totalDays <= 0) + { + Logger.LogInformation( + "EstimateNextPayout: non-positive totalDays between first and last reward. First={First}, Last={Last}, TotalDays={TotalDays}", + first, last, totalDays); + return (null, null); + } + + var avgInterval = totalDays / (eventCount - 1); + if (avgInterval <= 0) + { + Logger.LogInformation( + "EstimateNextPayout: non-positive avgInterval. TotalDays={TotalDays}, EventCount={EventCount}, AvgInterval={AvgInterval}", + totalDays, eventCount, avgInterval); + return (null, null); + } + + var estimatedNext = last.Value.AddDays(avgInterval); + Logger.LogInformation( + "EstimateNextPayout: success. First={First}, Last={Last}, EventCount={EventCount}, TotalDays={TotalDays}, AvgInterval={AvgInterval}, EstimatedNext={EstimatedNext}", + first, last, eventCount, totalDays, avgInterval, estimatedNext); + return (estimatedNext, avgInterval); + } + + /// + /// Computes payment status and percentage for a validator based on expected vs actual payouts. + /// + private static (string Status, double Percentage) GetPaymentStatus( + long actualDelegationPayouts, + long expectedDelegationRewards, + long actualStakingRestaked) + { + if (expectedDelegationRewards > 0) + { + var percentage = Math.Min(100, (double)actualDelegationPayouts / expectedDelegationRewards * 100); + string status = actualDelegationPayouts >= expectedDelegationRewards * 0.99 + ? "fully_paid" + : actualDelegationPayouts > expectedDelegationRewards + ? "overpaid" + : actualDelegationPayouts > 0 + ? "underpaid" + : "pending"; + if (status == "overpaid") + percentage = (double)actualDelegationPayouts / expectedDelegationRewards * 100; + return (status, percentage); + } + if (actualDelegationPayouts > 0 || actualStakingRestaked > 0) + return ("fully_paid", 100); + return ("pending", 0); + } + + /// + /// Builds per-cycle payment status for delegation rewards (paid / underpaid / pending / overpaid). + /// + private static List BuildDelegationByCycleStatus( + List sortedDelegationCycles, + long actualDelegationPayouts, + string validatorAddress, + string validatorAlias) + { + var result = new List(sortedDelegationCycles.Count); + long remainingPayout = actualDelegationPayouts; + + foreach (var cycleData in sortedDelegationCycles) + { + string cycleStatus = remainingPayout >= cycleData.Amount * 0.99 + ? "paid" + : remainingPayout > 0 + ? "underpaid" + : "pending"; + if (cycleStatus == "paid") + remainingPayout -= cycleData.Amount; + else if (remainingPayout > 0) + remainingPayout = 0; + + result.Add(new CyclePaymentStatus + { + Cycle = cycleData.Cycle, + ExpectedReward = cycleData.Amount, + Status = cycleStatus, + ValidatorAddress = validatorAddress, + ValidatorAlias = validatorAlias + }); + } + + if (remainingPayout > 0 && result.Count > 0) + result[result.Count - 1].Status = "overpaid"; + + result.Sort((a, b) => b.Cycle.CompareTo(a.Cycle)); + return result; + } + + /// + /// Build empty delegation info for non-existent or unresolved accounts + /// + private static DelegationInfo BuildEmptyDelegationInfo() + { + return new DelegationInfo + { + Summary = new DelegationSummary + { + IsDelegating = false, + DelegatedBalance = 0, + IsStaking = false, + StakedValidators = new List(), + TotalStakedBalance = 0, + TotalRewardsEarned = 0, + RewardsByValidator = new List(), + RewardsByCycle = new List(), + StakingPercentage = 0, + DelegationPercentage = 0, + TotalValidatorsUsed = 0, + CurrentValidatorCount = 0, + PastValidatorCount = 0, + TotalCyclesWithRewards = 0 + }, + ActualRewards = new ActualRewards + { + ByValidator = new List(), + AllStakingRewardEvents = new List() + }, + EstimatedNextPayout = null, + AvgPayoutIntervalDays = null, + LastVote = null + }; + } + + /// + /// Build delegation summary + /// + private DelegationSummary BuildDelegationSummary( + Account account, + bool isDelegating, + ValidatorInfo delegatedValidator, + long delegatedBalance, + DateTime? delegationTime, + List stakingInfo, + List delegatorRewards, + ActualRewards actualRewards, + long accountBalance) + { + var isStaking = stakingInfo.Any(); + var totalStakedBalance = stakingInfo.Sum(s => s.StakedBalance); + + var stakedValidators = stakingInfo.Select(s => new StakedValidatorInfo + { + Baker = new ValidatorInfo { Address = s.BakerAddress, Alias = s.BakerAlias }, + StakedBalance = s.StakedBalance + }).ToList(); + + var rewardsByValidator = actualRewards.ByValidator.Select(v => new ValidatorRewardSummary + { + Validator = new ValidatorInfo { Address = v.Address, Alias = v.Alias }, + TotalRewards = v.ActualTotalReceived, + CycleCount = v.CycleCount, + FirstCycle = v.FirstCycle, + LastCycle = v.LastCycle, + TotalStakingRewards = v.ActualStakingRestaked, + TotalDelegationRewards = v.ActualDelegationPayouts, + IsCurrentValidator = (delegatedValidator?.Address == v.Address) || stakingInfo.Any(s => s.BakerAddress == v.Address) + }).ToList(); + + var rewardsByCycle = delegatorRewards.Select(r => + { + var bakerTotalSharedRewards = r.BlockRewardsStakedShared + r.EndorsementRewardsStakedShared; + var userStakedBalance = r.StakedBalance; + var externalStakedBalance = r.ExternalStakedBalance; + long stakingReward = 0; + if (externalStakedBalance > 0 && userStakedBalance > 0) + { + stakingReward = (long)((double)userStakedBalance / externalStakedBalance * bakerTotalSharedRewards); + } + + var validatorDelegationRewards = r.BlockRewardsDelegated + r.EndorsementRewardsDelegated; + var userDelegatedBalance = r.DelegatedBalance; + var validatorStakingBalance = r.StakingBalance; + long delegationReward = 0; + if (validatorStakingBalance > 0 && userDelegatedBalance > 0) + { + delegationReward = (long)((double)userDelegatedBalance / validatorStakingBalance * validatorDelegationRewards); + } + + return new CycleRewardSummary + { + Cycle = r.Cycle, + Rewards = stakingReward + delegationReward, + DelegatedBalance = r.DelegatedBalance, + StakedBalance = r.StakedBalance, + Validator = new ValidatorInfo { Address = r.Baker?.Address, Alias = r.Baker?.Name }, + StakingRewards = stakingReward, + DelegationRewards = delegationReward + }; + }).ToList(); + + double stakingPercentage = accountBalance > 0 ? (double)totalStakedBalance / accountBalance * 100 : 0; + double delegationPercentage = accountBalance > 0 && isDelegating ? (double)delegatedBalance / accountBalance * 100 : 0; + + var allValidators = new HashSet(); + var currentValidators = new HashSet(); + if (isDelegating && delegatedValidator != null) + { + allValidators.Add(delegatedValidator.Address); + currentValidators.Add(delegatedValidator.Address); + } + foreach (var staker in stakingInfo) + { + allValidators.Add(staker.BakerAddress); + currentValidators.Add(staker.BakerAddress); + } + foreach (var reward in delegatorRewards) + { + if (reward.Baker != null) + allValidators.Add(reward.Baker.Address); + } + + var cycles = delegatorRewards.Select(r => r.Cycle).ToList(); + int? firstRewardCycle = cycles.Any() ? cycles.Min() : null; + int? lastRewardCycle = cycles.Any() ? cycles.Max() : null; + int totalCyclesWithRewards = cycles.Distinct().Count(); + + return new DelegationSummary + { + IsDelegating = isDelegating, + DelegatedValidator = delegatedValidator, + DelegatedBalance = delegatedBalance, + DelegationTime = delegationTime, + IsStaking = isStaking, + StakedValidators = stakedValidators, + TotalStakedBalance = totalStakedBalance, + TotalRewardsEarned = actualRewards.TotalActualRewards, + RewardsByValidator = rewardsByValidator, + RewardsByCycle = rewardsByCycle, + StakingPercentage = stakingPercentage, + DelegationPercentage = delegationPercentage, + TotalValidatorsUsed = allValidators.Count, + CurrentValidatorCount = currentValidators.Count, + PastValidatorCount = allValidators.Count - currentValidators.Count, + FirstRewardCycle = firstRewardCycle, + LastRewardCycle = lastRewardCycle, + TotalCyclesWithRewards = totalCyclesWithRewards + }; + } + + /// + /// Get last vote information. Call only when accountId is known to exist. + /// + private async Task GetLastVoteAsync(int accountId) + { + try + { + var ballots = await Operations.GetBallots( + sender: new AccountParameter { Eq = accountId }, + level: null, + timestamp: null, + epoch: null, + period: null, + proposal: null, + vote: null, + sort: new SortParameter { Desc = "id" }, + offset: new OffsetParameter { El = 0 }, + limit: 1, + quote: Symbols.None + ); + + var ballot = ballots.FirstOrDefault(); + if (ballot == null) + return null; + + if (ballot is Models.BallotOperation ballotOp) + { + return new LastVoteInfo + { + Proposal = ballotOp.Proposal?.Hash, + Vote = ballotOp.Vote, + Period = ballotOp.Period?.Index ?? 0, + Timestamp = ballotOp.Timestamp + }; + } + } + catch + { + // Ignore errors, last vote is optional + } + + return null; + } + + /// + /// Result of aggregating staking and expected rewards from delegator rewards per cycle. + /// + private class AggregatedRewardsFromDelegators + { + public Dictionary StakingByValidator { get; set; } + public long StakingRestakedTotal { get; set; } + public int StakingRestakeCount { get; set; } + public List AllStakingRewardEvents { get; set; } + public Dictionary ExpectedByValidator { get; set; } + } + + private class ValidatorPayoutData + { + public long Amount { get; set; } + public int Count { get; set; } + public string Alias { get; set; } + } + + private class ValidatorStakingData + { + public long Amount { get; set; } + public int Count { get; set; } + public string Alias { get; set; } + public List ByCycle { get; set; } + } + + private class ValidatorExpectedData + { + public long StakingRewards { get; set; } + public long DelegationRewards { get; set; } + public string Alias { get; set; } + public List Cycles { get; set; } + public List DelegationByCycle { get; set; } + } + + private class CycleDelegationData + { + public int Cycle { get; set; } + public long Amount { get; set; } + } + } +} diff --git a/Mvkt.Api/Services/Delegation/DelegationConfig.cs b/Mvkt.Api/Services/Delegation/DelegationConfig.cs new file mode 100644 index 000000000..e253769f9 --- /dev/null +++ b/Mvkt.Api/Services/Delegation/DelegationConfig.cs @@ -0,0 +1,16 @@ +namespace Mvkt.Api.Services +{ + /// + /// Optional delegation configuration (e.g. validator payout addresses). + /// Bound from configuration section "Delegation". + /// In appsettings.json add: "Delegation": { "ValidatorPayoutAddresses": { "validator_address": [ "payout_address" ] } } + /// + public class DelegationConfig + { + /// + /// Maps validator address (baker) to a list of alternative addresses used to send delegation payouts. + /// Transactions from these addresses to the user are attributed to the validator. + /// + public Dictionary> ValidatorPayoutAddresses { get; set; } = new(); + } +} diff --git a/Mvkt.Api/appsettings.json b/Mvkt.Api/appsettings.json index eca5cac0f..419b6b625 100644 --- a/Mvkt.Api/appsettings.json +++ b/Mvkt.Api/appsettings.json @@ -61,5 +61,12 @@ } } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Delegation": { + "ValidatorPayoutAddresses": { + "mv1KryptaWtsDi7EozpfoBjKbKbf4zgMvpj8": [ + "mv1HJffErD9CMKp93BRf3CANyiHL8sSqhpay" + ] + } + } } From ed784be185d841e21df8e1ab98f5010804efd452 Mon Sep 17 00:00:00 2001 From: 0xVoronov Date: Thu, 19 Feb 2026 10:23:41 +0300 Subject: [PATCH 2/3] Updated settings --- .../Api/TestDelegationInfoQueries.cs | 153 -------------- .../Api/TestDelegationSummaryQueries.cs | 197 ++++++++++++++++++ Mvkt.Api.Tests/Api/settings.json | 10 +- Mvkt.Api/Controllers/AccountsController.cs | 33 +-- Mvkt.Api/Controllers/RewardsController.cs | 41 +++- Mvkt.Api/Program.cs | 2 +- Mvkt.Api/Repositories/RewardsRepository.cs | 2 +- ...Service.cs => DelegationSummaryService.cs} | 10 +- 8 files changed, 249 insertions(+), 199 deletions(-) delete mode 100644 Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs create mode 100644 Mvkt.Api.Tests/Api/TestDelegationSummaryQueries.cs rename Mvkt.Api/Services/Delegation/{AccountDelegationInfoService.cs => DelegationSummaryService.cs} (99%) diff --git a/Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs b/Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs deleted file mode 100644 index 0ca5cf2f8..000000000 --- a/Mvkt.Api.Tests/Api/TestDelegationInfoQueries.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using Dynamic.Json; -using Dynamic.Json.Extensions; -using Xunit; - -namespace Mvkt.Api.Tests.Api -{ - public class TestDelegationInfoQueries : IClassFixture - { - readonly SettingsFixture Settings; - readonly HttpClient Client; - - public TestDelegationInfoQueries(SettingsFixture settings) - { - Settings = settings; - Client = settings.Client; - } - - [Fact] - public async Task TestAccountEndpoint_DoesNotIncludeDelegationInfo() - { - dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}"); - - Assert.True(res is DJsonObject); - Assert.Null(res.delegationInfo); - Assert.NotNull(res.address); - Assert.NotNull(res.balance); - Assert.NotNull(res.type); - } - - [Fact] - public async Task TestDelegationInfoEndpoint_ReturnsDelegationInfo() - { - dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info"); - - Assert.True(res is DJsonObject); - Assert.NotNull(res.summary); - Assert.NotNull(res.actualRewards); - } - - [Fact] - public async Task TestDelegationInfoStructure() - { - dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info"); - - Assert.True(res is DJsonObject); - - Assert.NotNull(res.summary); - Assert.NotNull(res.summary.isDelegating); - Assert.NotNull(res.summary.isStaking); - Assert.NotNull(res.summary.totalRewardsEarned); - Assert.NotNull(res.summary.rewardsByValidator); - Assert.NotNull(res.summary.rewardsByCycle); - - Assert.NotNull(res.actualRewards); - Assert.NotNull(res.actualRewards.totalActualRewards); - Assert.NotNull(res.actualRewards.delegationPayouts); - Assert.NotNull(res.actualRewards.stakingRewardsRestaked); - Assert.NotNull(res.actualRewards.expectedTotalRewards); - Assert.NotNull(res.actualRewards.pendingDelegation); - Assert.NotNull(res.actualRewards.byValidator); - } - - [Fact] - public async Task TestDelegatorAccountDelegationInfo() - { - var delegators = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegators?limit=1"); - - if (delegators is DJsonArray delegatorsArray && delegatorsArray.Count > 0) - { - dynamic delegator = delegatorsArray.First(); - var delegatorAddress = (string)delegator.address; - - dynamic res = await Client.GetJsonAsync($"/v1/accounts/{delegatorAddress}/delegation-info"); - - Assert.True(res is DJsonObject); - Assert.NotNull(res.summary); - Assert.NotNull(res.actualRewards); - } - } - - [Fact] - public async Task TestNonExistentAccountDelegationInfo_Returns200WithEmptyInfo() - { - // Non-existent mv address: API returns 200 with empty delegation info (no 404) - var response = await Client.GetAsync("/v1/accounts/mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg1c/delegation-info"); - Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - - dynamic res = await Client.GetJsonAsync("/v1/accounts/mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg1c/delegation-info"); - Assert.True(res is DJsonObject); - Assert.NotNull(res.summary); - Assert.False((bool)res.summary.isDelegating); - Assert.False((bool)res.summary.isStaking); - Assert.NotNull(res.summary.stakedValidators); - Assert.True(res.summary.stakedValidators is DJsonArray); - Assert.Equal(0, ((DJsonArray)res.summary.stakedValidators).Count); - Assert.Equal(0L, (long)res.summary.totalStakedBalance); - Assert.Equal(0L, (long)res.summary.totalRewardsEarned); - Assert.NotNull(res.actualRewards); - Assert.NotNull(res.actualRewards.byValidator); - Assert.True(res.actualRewards.byValidator is DJsonArray); - Assert.Equal(0, ((DJsonArray)res.actualRewards.byValidator).Count); - } - - [Fact] - public async Task TestDelegationInfoByValidatorTracking() - { - dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info"); - - Assert.True(res is DJsonObject); - Assert.NotNull(res.actualRewards); - - var byValidator = res.actualRewards.byValidator; - if (byValidator is DJsonArray arr && arr.Count > 0) - { - dynamic first = arr.First(); - Assert.NotNull(first.address); - Assert.NotNull(first.paymentStatus); - Assert.NotNull(first.expectedDelegationRewards); - Assert.NotNull(first.actualDelegationPayouts); - Assert.NotNull(first.pendingDelegation); - } - } - - [Fact] - public async Task TestDelegationInfo_WithLegacyParameter() - { - var resLegacyTrue = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info?legacy=true"); - var resLegacyFalse = await Client.GetJsonAsync($"/v1/accounts/{Settings.Baker}/delegation-info?legacy=false"); - - Assert.True(resLegacyTrue is DJsonObject); - Assert.True(resLegacyFalse is DJsonObject); - Assert.NotNull(resLegacyTrue.summary); - Assert.NotNull(resLegacyFalse.summary); - Assert.NotNull(resLegacyTrue.actualRewards); - Assert.NotNull(resLegacyFalse.actualRewards); - } - - [Fact] - public async Task TestContractDelegationInfo() - { - dynamic res = await Client.GetJsonAsync($"/v1/accounts/{Settings.Originator}/delegation-info"); - - Assert.True(res is DJsonObject); - Assert.NotNull(res.summary); - Assert.NotNull(res.actualRewards); - Assert.NotNull(res.summary.isDelegating); - Assert.NotNull(res.summary.isStaking); - } - } -} diff --git a/Mvkt.Api.Tests/Api/TestDelegationSummaryQueries.cs b/Mvkt.Api.Tests/Api/TestDelegationSummaryQueries.cs new file mode 100644 index 000000000..ce32eec0a --- /dev/null +++ b/Mvkt.Api.Tests/Api/TestDelegationSummaryQueries.cs @@ -0,0 +1,197 @@ +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Dynamic.Json; +using Dynamic.Json.Extensions; +using Xunit; + +namespace Mvkt.Api.Tests.Api +{ + public class TestDelegationSummaryQueries : IClassFixture + { + readonly SettingsFixture Settings; + readonly HttpClient Client; + + public TestDelegationSummaryQueries(SettingsFixture settings) + { + Settings = settings; + Client = settings.Client; + } + + [Fact] + public async Task TestSummary_ReturnsFullStructure() + { + dynamic res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/summary"); + + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.NotNull(res.actualRewards); + + Assert.NotNull(res.summary.isDelegating); + Assert.NotNull(res.summary.isStaking); + Assert.NotNull(res.summary.totalRewardsEarned); + Assert.NotNull(res.summary.rewardsByValidator); + Assert.NotNull(res.summary.rewardsByCycle); + Assert.NotNull(res.summary.totalValidatorsUsed); + Assert.NotNull(res.summary.currentValidatorCount); + Assert.NotNull(res.summary.pastValidatorCount); + Assert.NotNull(res.summary.totalCyclesWithRewards); + + Assert.NotNull(res.actualRewards.totalActualRewards); + Assert.NotNull(res.actualRewards.delegationPayouts); + Assert.NotNull(res.actualRewards.stakingRewardsRestaked); + Assert.NotNull(res.actualRewards.expectedTotalRewards); + Assert.NotNull(res.actualRewards.pendingDelegation); + Assert.NotNull(res.actualRewards.byValidator); + + if (res.summary.rewardsByCycle is DJsonArray arr && arr.Count > 0) + { + Assert.NotNull(res.summary.firstRewardCycle); + Assert.NotNull(res.summary.lastRewardCycle); + Assert.True((int)res.summary.totalCyclesWithRewards >= 0); + } + } + + [Fact] + public async Task TestSummary_NonExistentAddress_Returns200WithEmptyInfo() + { + const string nonExistent = "/v1/rewards/delegators/mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg1c/summary"; + var response = await Client.GetAsync(nonExistent); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + + dynamic res = await Client.GetJsonAsync(nonExistent); + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.NotNull(res.actualRewards); + Assert.False((bool)res.summary.isDelegating); + Assert.False((bool)res.summary.isStaking); + Assert.NotNull(res.summary.stakedValidators); + Assert.True(res.summary.stakedValidators is DJsonArray); + Assert.Equal(0, ((DJsonArray)res.summary.stakedValidators).Count); + Assert.Equal(0L, (long)res.summary.totalStakedBalance); + Assert.Equal(0L, (long)res.summary.totalRewardsEarned); + Assert.NotNull(res.actualRewards.byValidator); + Assert.True(res.actualRewards.byValidator is DJsonArray); + Assert.Equal(0, ((DJsonArray)res.actualRewards.byValidator).Count); + } + + [Fact] + public async Task TestDelegationSummary_WithLegacyParameter() + { + var resLegacyTrue = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/summary?legacy=true"); + var resLegacyFalse = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/summary?legacy=false"); + + Assert.True(resLegacyTrue is DJsonObject); + Assert.True(resLegacyFalse is DJsonObject); + Assert.NotNull(resLegacyTrue.summary); + Assert.NotNull(resLegacyFalse.summary); + Assert.NotNull(resLegacyTrue.actualRewards); + Assert.NotNull(resLegacyFalse.actualRewards); + } + + [Fact] + public async Task TestContractDelegationSummary() + { + dynamic res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Originator}/summary"); + + Assert.True(res is DJsonObject); + Assert.NotNull(res.summary); + Assert.NotNull(res.actualRewards); + Assert.NotNull(res.summary.isDelegating); + Assert.NotNull(res.summary.isStaking); + } + + [Fact] + public async Task TestSummary_RewardsByCycle_OrderAndStructure() + { + dynamic res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/summary"); + var rewardsByCycle = res.summary.rewardsByCycle; + if (rewardsByCycle is DJsonArray arr && arr.Count > 0) + { + dynamic first = arr.First(); + Assert.NotNull(first.cycle); + Assert.NotNull(first.rewards); + Assert.NotNull(first.delegatedBalance); + Assert.NotNull(first.stakedBalance); + Assert.NotNull(first.validator); + + if (arr.Count >= 2) + { + for (int i = 0; i < arr.Count - 1; i++) + { + int current = (int)((dynamic)arr.ElementAt(i)).cycle; + int next = (int)((dynamic)arr.ElementAt(i + 1)).cycle; + Assert.True(current >= next, $"rewardsByCycle should be descending: at index {i} cycle={current}, at {i + 1} cycle={next}"); + } + } + } + } + + [Fact] + public async Task TestSummary_ActualRewards_AllStakingRewardEvents_Structure() + { + dynamic res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/summary"); + Assert.NotNull(res.actualRewards); + var events = res.actualRewards.allStakingRewardEvents; + Assert.NotNull(events); + Assert.True(events is DJsonArray); + if (((DJsonArray)events).Count > 0) + { + dynamic ev = ((DJsonArray)events).First(); + Assert.NotNull(ev.cycle); + Assert.NotNull(ev.amount); + Assert.NotNull(ev.validatorAddress); + } + } + + [Fact] + public async Task TestSummary_ByValidator() + { + var validPaymentStatuses = new[] { "fully_paid", "pending", "underpaid", "overpaid" }; + var validCycleStatuses = new[] { "paid", "pending", "underpaid", "overpaid" }; + + dynamic res = await Client.GetJsonAsync($"/v1/rewards/delegators/{Settings.Baker}/summary"); + Assert.NotNull(res.actualRewards); + var byValidator = res.actualRewards.byValidator; + + if (byValidator is DJsonArray arr && arr.Count > 0) + { + dynamic first = arr.First(); + Assert.NotNull(first.address); + Assert.NotNull(first.paymentStatus); + Assert.NotNull(first.expectedDelegationRewards); + Assert.NotNull(first.actualDelegationPayouts); + Assert.NotNull(first.pendingDelegation); + + foreach (dynamic v in arr) + { + string status = (string)v.paymentStatus; + Assert.NotNull(status); + Assert.Contains(status, validPaymentStatuses); + + var delegationByCycle = v.delegationByCycle; + Assert.NotNull(delegationByCycle); + if (delegationByCycle is DJsonArray dcArr && dcArr.Count > 0) + { + foreach (dynamic dc in dcArr) + { + Assert.NotNull(dc.cycle); + Assert.NotNull(dc.expectedReward); + Assert.NotNull(dc.status); + Assert.Contains((string)dc.status, validCycleStatuses); + } + } + + var stakingByCycle = v.stakingRewardsByCycle; + Assert.NotNull(stakingByCycle); + if (stakingByCycle is DJsonArray scArr && scArr.Count > 0) + { + dynamic sc = scArr.First(); + Assert.NotNull(sc.cycle); + Assert.NotNull(sc.amount); + } + } + } + } + } +} diff --git a/Mvkt.Api.Tests/Api/settings.json b/Mvkt.Api.Tests/Api/settings.json index a9eabf190..27fd696f7 100644 --- a/Mvkt.Api.Tests/Api/settings.json +++ b/Mvkt.Api.Tests/Api/settings.json @@ -1,7 +1,7 @@ { - "url": "http://localhost:5000/", - "baker": "mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg8g", - "originator": "KT1WdbBw5DXF9fXN378v8VgrPqTsCKu2BPgD", - "delegator": "mv1LMue3zJSujtVeEQneaK7VZAeg8WSF3w5y", - "cycle": 137 + "url": "https://api.mavryk.network/", + "baker": "mv1MtuibrK2PZpbJLhe1zkxfLq9HXwRsFWuZ", + "originator": "mv19MAVgCDwzuNMWprbHrUZhznoH8n9NWGWt", + "delegator": "mv1NvY27DrRFvgUMV86WoJKELVCyRa2ZRt9g", + "cycle": 50 } diff --git a/Mvkt.Api/Controllers/AccountsController.cs b/Mvkt.Api/Controllers/AccountsController.cs index 690c46a5d..7c2d196d2 100644 --- a/Mvkt.Api/Controllers/AccountsController.cs +++ b/Mvkt.Api/Controllers/AccountsController.cs @@ -5,7 +5,6 @@ using Mvkt.Api.Repositories; using Mvkt.Api.Services; using Mvkt.Api.Services.Cache; -using Mvkt.Api.Services.Delegation; namespace Mvkt.Api.Controllers { @@ -18,22 +17,19 @@ public class AccountsController : ControllerBase readonly ReportRepository Reports; readonly StateCache State; readonly ResponseCacheService ResponseCache; - readonly AccountDelegationInfoService DelegationInfoService; public AccountsController( AccountRepository accounts, BalanceHistoryRepository history, ReportRepository reports, StateCache state, - ResponseCacheService responseCache, - AccountDelegationInfoService delegationInfoService) + ResponseCacheService responseCache) { Accounts = accounts; History = history; Reports = reports; State = state; ResponseCache = responseCache; - DelegationInfoService = delegationInfoService; } /// @@ -194,33 +190,6 @@ public async Task> GetByAddress( return this.Bytes(cached); } - /// - /// Get delegation and staking information for an account - /// - /// - /// Returns comprehensive delegation and staking information for the specified account. - /// Consolidates data from multiple sources: account status, expected rewards from cycle data, - /// actual rewards (transactions and restake events), payment status per validator and per cycle. - /// - /// Account address - /// If `true` (by default), the account is resolved using legacy semantics. This is a part of a deprecation mechanism, allowing smooth migration. - /// - [HttpGet("{address}/delegation-info")] - public async Task> GetDelegationInfo( - [Required][Address] string address, - bool legacy = true) - { - var query = ResponseCacheService.BuildKey(Request.Path.Value, ("legacy", legacy)); - - if (ResponseCache.TryGet(query, out var cached)) - return this.Bytes(cached); - - var res = await DelegationInfoService.GetDelegationInfoAsync(address, legacy); - cached = ResponseCache.Set(query, res); - - return this.Bytes(cached); - } - /// /// Get account contracts /// diff --git a/Mvkt.Api/Controllers/RewardsController.cs b/Mvkt.Api/Controllers/RewardsController.cs index 4e8e515d9..babde204b 100644 --- a/Mvkt.Api/Controllers/RewardsController.cs +++ b/Mvkt.Api/Controllers/RewardsController.cs @@ -1,8 +1,10 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Mvkt.Api.Models; using Mvkt.Api.Repositories; +using Mvkt.Api.Services; +using Mvkt.Api.Services.Delegation; namespace Mvkt.Api.Controllers { @@ -12,11 +14,19 @@ public class RewardsController : ControllerBase { private readonly RewardsRepository Rewards; private readonly StakingRepository Staking; + private readonly ResponseCacheService ResponseCache; + private readonly DelegationSummaryService DelegationSummaryService; - public RewardsController(RewardsRepository rewards, StakingRepository staking) + public RewardsController( + RewardsRepository rewards, + StakingRepository staking, + ResponseCacheService responseCache, + DelegationSummaryService delegationSummaryService) { Rewards = rewards; Staking = staking; + ResponseCache = responseCache; + DelegationSummaryService = delegationSummaryService; } /// @@ -171,6 +181,33 @@ public async Task GetDelegatorRewardsByCycle([Required][Addres return (await Rewards.GetDelegatorRewards(address, cycle, null, null, 100, quote)).FirstOrDefault(); } + /// + /// Get delegation and staking information for a delegator + /// + /// + /// Returns comprehensive delegation and staking information for the specified account. + /// Consolidates data from multiple sources: account status, expected rewards from cycle data, + /// actual rewards (transactions and restake events), payment status per validator and per cycle. + /// + /// Delegator address + /// If `true` (by default), the account is resolved using legacy semantics. This is a part of a deprecation mechanism, allowing smooth migration. + /// + [HttpGet("delegators/{address}/summary")] + public async Task> GetDelegationInfo( + [Required][Address] string address, + bool legacy = true) + { + var query = ResponseCacheService.BuildKey(Request.Path.Value, ("legacy", legacy)); + + if (ResponseCache.TryGet(query, out var cached)) + return this.Bytes(cached); + + var res = await DelegationSummaryService.GetDelegationInfoAsync(address, legacy); + cached = ResponseCache.Set(query, res); + + return this.Bytes(cached); + } + /// /// Get reward split /// diff --git a/Mvkt.Api/Program.cs b/Mvkt.Api/Program.cs index a9d467e0c..536be6387 100644 --- a/Mvkt.Api/Program.cs +++ b/Mvkt.Api/Program.cs @@ -87,7 +87,7 @@ var config = serviceProvider.GetRequiredService(); return config.GetSection("Delegation").Get() ?? new DelegationConfig(); }); -builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.Services.AddAuthService(builder.Configuration); builder.Services.AddSingleton(); diff --git a/Mvkt.Api/Repositories/RewardsRepository.cs b/Mvkt.Api/Repositories/RewardsRepository.cs index bbaf88f00..065f842d1 100644 --- a/Mvkt.Api/Repositories/RewardsRepository.cs +++ b/Mvkt.Api/Repositories/RewardsRepository.cs @@ -1,4 +1,4 @@ -using Dapper; +using Dapper; using Npgsql; using Mvkt.Api.Models; using Mvkt.Api.Services.Cache; diff --git a/Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs b/Mvkt.Api/Services/Delegation/DelegationSummaryService.cs similarity index 99% rename from Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs rename to Mvkt.Api/Services/Delegation/DelegationSummaryService.cs index 4260c0ff2..7bc4637bd 100644 --- a/Mvkt.Api/Services/Delegation/AccountDelegationInfoService.cs +++ b/Mvkt.Api/Services/Delegation/DelegationSummaryService.cs @@ -10,26 +10,26 @@ namespace Mvkt.Api.Services.Delegation /// Service for building comprehensive delegation and staking information for an account /// Consolidates data from multiple endpoints (account, rewards, staking, transactions, staking updates) /// - public class AccountDelegationInfoService + public class DelegationSummaryService { readonly AccountRepository Accounts; readonly RewardsRepository Rewards; readonly StakingRepository Staking; readonly OperationRepository Operations; readonly VotingRepository Voting; - readonly ILogger Logger; + readonly ILogger Logger; readonly AccountsCache AccountsCache; readonly ProtocolsCache Protocols; readonly TimeCache Times; readonly DelegationConfig DelegationConfig; - public AccountDelegationInfoService( + public DelegationSummaryService( AccountRepository accounts, RewardsRepository rewards, StakingRepository staking, OperationRepository operations, VotingRepository voting, - ILogger logger, + ILogger logger, AccountsCache accountsCache, ProtocolsCache protocols, TimeCache times, @@ -173,7 +173,7 @@ private async Task> GetDelegatorRewardsAsync(string addre var rewards = await Rewards.GetDelegatorRewards( address, cycle: null, - sort: new SortParameter { Asc = "cycle" }, + sort: new SortParameter { Desc = "cycle" }, offset: new OffsetParameter { El = 0 }, limit: 10000, quote: Symbols.None From 3f1312363eb9ef241841009c6f31f66c5157b735 Mon Sep 17 00:00:00 2001 From: 0xVoronov Date: Mon, 23 Feb 2026 07:24:32 +0300 Subject: [PATCH 3/3] Resolved comments and provided optimizations for models --- Mvkt.Api.Tests/Api/settings.json | 10 +++--- .../ActualRewards.cs | 18 +++++------ .../CyclePaymentStatus.cs | 2 +- .../CycleRewardSummary.cs | 12 +++---- .../DelegationInfo.cs | 0 .../DelegationSummary.cs | 8 ++--- .../LastVoteInfo.cs | 0 .../StakedValidatorInfo.cs | 4 +-- .../{DelegationInfo => Baking}/StakerData.cs | 2 +- .../StakingRewardEvent.cs | 4 +-- Mvkt.Api/Models/Baking/StakingUpdate.cs | 2 +- Mvkt.Api/Models/Baking/UnstakeRequest.cs | 8 ++--- .../ValidatorRewardSummary.cs | 8 ++--- .../ValidatorRewardTracking.cs | 22 ++++++------- .../DelegationInfo/CycleStakingReward.cs | 23 -------------- .../Models/DelegationInfo/ValidatorInfo.cs | 18 ----------- .../Delegation/DelegationSummaryService.cs | 31 ++++++++++--------- 17 files changed, 66 insertions(+), 106 deletions(-) rename Mvkt.Api/Models/{DelegationInfo => Baking}/ActualRewards.cs (80%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/CyclePaymentStatus.cs (93%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/CycleRewardSummary.cs (71%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/DelegationInfo.cs (100%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/DelegationSummary.cs (91%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/LastVoteInfo.cs (100%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/StakedValidatorInfo.cs (76%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/StakerData.cs (91%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/StakingRewardEvent.cs (90%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/ValidatorRewardSummary.cs (82%) rename Mvkt.Api/Models/{DelegationInfo => Baking}/ValidatorRewardTracking.cs (81%) delete mode 100644 Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs delete mode 100644 Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs diff --git a/Mvkt.Api.Tests/Api/settings.json b/Mvkt.Api.Tests/Api/settings.json index 27fd696f7..2aa0c76f0 100644 --- a/Mvkt.Api.Tests/Api/settings.json +++ b/Mvkt.Api.Tests/Api/settings.json @@ -1,7 +1,7 @@ { - "url": "https://api.mavryk.network/", - "baker": "mv1MtuibrK2PZpbJLhe1zkxfLq9HXwRsFWuZ", - "originator": "mv19MAVgCDwzuNMWprbHrUZhznoH8n9NWGWt", - "delegator": "mv1NvY27DrRFvgUMV86WoJKELVCyRa2ZRt9g", - "cycle": 50 + "url": "https://basenet.api.mavryk.network/", + "baker": "mv1V4h45W3p4e1sjSBvRkK2uYbvkTnSuHg8g", + "originator": "KT1WdbBw5DXF9fXN378v8VgrPqTsCKu2BPgD", + "delegator": "mv1LMue3zJSujtVeEQneaK7VZAeg8WSF3w5y", + "cycle": 137 } diff --git a/Mvkt.Api/Models/DelegationInfo/ActualRewards.cs b/Mvkt.Api/Models/Baking/ActualRewards.cs similarity index 80% rename from Mvkt.Api/Models/DelegationInfo/ActualRewards.cs rename to Mvkt.Api/Models/Baking/ActualRewards.cs index 06aef7d43..7bdbbe097 100644 --- a/Mvkt.Api/Models/DelegationInfo/ActualRewards.cs +++ b/Mvkt.Api/Models/Baking/ActualRewards.cs @@ -6,12 +6,12 @@ namespace Mvkt.Api.Models public class ActualRewards { /// - /// Total actual rewards received (micro tez) + /// Total actual rewards received /// public long TotalActualRewards { get; set; } /// - /// Delegation payouts received (micro tez) + /// Delegation payouts received /// public long DelegationPayouts { get; set; } @@ -21,7 +21,7 @@ public class ActualRewards public int DelegationPayoutCount { get; set; } /// - /// Staking rewards auto-restaked (micro tez) + /// Staking rewards auto-restaked /// public long StakingRewardsRestaked { get; set; } @@ -31,32 +31,32 @@ public class ActualRewards public int StakingRestakeCount { get; set; } /// - /// Expected delegation rewards (micro tez) + /// Expected delegation rewards /// public long ExpectedDelegationRewards { get; set; } /// - /// Expected staking rewards (micro tez) + /// Expected staking rewards /// public long ExpectedStakingRewards { get; set; } /// - /// Expected total rewards (micro tez) + /// Expected total rewards /// public long ExpectedTotalRewards { get; set; } /// - /// Pending delegation rewards not yet paid (micro tez) + /// Pending delegation rewards not yet paid /// public long PendingDelegation { get; set; } /// - /// Pending staking rewards (micro tez) + /// Pending staking rewards /// public long PendingStaking { get; set; } /// - /// Total pending rewards (micro tez) + /// Total pending rewards /// public long PendingTotal { get; set; } diff --git a/Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs b/Mvkt.Api/Models/Baking/CyclePaymentStatus.cs similarity index 93% rename from Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs rename to Mvkt.Api/Models/Baking/CyclePaymentStatus.cs index b1439fdb7..733586384 100644 --- a/Mvkt.Api/Models/DelegationInfo/CyclePaymentStatus.cs +++ b/Mvkt.Api/Models/Baking/CyclePaymentStatus.cs @@ -11,7 +11,7 @@ public class CyclePaymentStatus public int Cycle { get; set; } /// - /// Expected reward for this cycle (micro tez) + /// Expected reward for this cycle /// public long ExpectedReward { get; set; } diff --git a/Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs b/Mvkt.Api/Models/Baking/CycleRewardSummary.cs similarity index 71% rename from Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs rename to Mvkt.Api/Models/Baking/CycleRewardSummary.cs index 677fa02e4..4fb5a6d39 100644 --- a/Mvkt.Api/Models/DelegationInfo/CycleRewardSummary.cs +++ b/Mvkt.Api/Models/Baking/CycleRewardSummary.cs @@ -11,32 +11,32 @@ public class CycleRewardSummary public int Cycle { get; set; } /// - /// Total rewards for this cycle (micro tez) + /// Total rewards for this cycle /// public long Rewards { get; set; } /// - /// Delegated balance during this cycle (micro tez) + /// Delegated balance during this cycle /// public long DelegatedBalance { get; set; } /// - /// Staked balance during this cycle (micro tez) + /// Staked balance during this cycle /// public long StakedBalance { get; set; } /// /// Validator for this cycle /// - public ValidatorInfo Validator { get; set; } + public Alias Validator { get; set; } /// - /// Staking rewards for this cycle (micro tez) + /// Staking rewards for this cycle /// public long StakingRewards { get; set; } /// - /// Delegation rewards for this cycle (micro tez) + /// Delegation rewards for this cycle /// public long DelegationRewards { get; set; } } diff --git a/Mvkt.Api/Models/DelegationInfo/DelegationInfo.cs b/Mvkt.Api/Models/Baking/DelegationInfo.cs similarity index 100% rename from Mvkt.Api/Models/DelegationInfo/DelegationInfo.cs rename to Mvkt.Api/Models/Baking/DelegationInfo.cs diff --git a/Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs b/Mvkt.Api/Models/Baking/DelegationSummary.cs similarity index 91% rename from Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs rename to Mvkt.Api/Models/Baking/DelegationSummary.cs index 1f6bd5cb6..40efd70a0 100644 --- a/Mvkt.Api/Models/DelegationInfo/DelegationSummary.cs +++ b/Mvkt.Api/Models/Baking/DelegationSummary.cs @@ -13,10 +13,10 @@ public class DelegationSummary /// /// Current delegated validator /// - public ValidatorInfo DelegatedValidator { get; set; } + public Alias DelegatedValidator { get; set; } /// - /// Amount delegated (micro tez) + /// Amount delegated /// public long DelegatedBalance { get; set; } @@ -36,12 +36,12 @@ public class DelegationSummary public List StakedValidators { get; set; } /// - /// Total amount staked across all validators (micro tez) + /// Total amount staked across all validators /// public long TotalStakedBalance { get; set; } /// - /// Total rewards earned (delegation + staking) (micro tez) + /// Total rewards earned (delegation + staking) /// public long TotalRewardsEarned { get; set; } diff --git a/Mvkt.Api/Models/DelegationInfo/LastVoteInfo.cs b/Mvkt.Api/Models/Baking/LastVoteInfo.cs similarity index 100% rename from Mvkt.Api/Models/DelegationInfo/LastVoteInfo.cs rename to Mvkt.Api/Models/Baking/LastVoteInfo.cs diff --git a/Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs b/Mvkt.Api/Models/Baking/StakedValidatorInfo.cs similarity index 76% rename from Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs rename to Mvkt.Api/Models/Baking/StakedValidatorInfo.cs index 6ebc91601..47158b390 100644 --- a/Mvkt.Api/Models/DelegationInfo/StakedValidatorInfo.cs +++ b/Mvkt.Api/Models/Baking/StakedValidatorInfo.cs @@ -8,10 +8,10 @@ public class StakedValidatorInfo /// /// Validator information /// - public ValidatorInfo Baker { get; set; } + public Alias Baker { get; set; } /// - /// Amount staked with this validator (micro tez) + /// Amount staked with this validator /// public long StakedBalance { get; set; } } diff --git a/Mvkt.Api/Models/DelegationInfo/StakerData.cs b/Mvkt.Api/Models/Baking/StakerData.cs similarity index 91% rename from Mvkt.Api/Models/DelegationInfo/StakerData.cs rename to Mvkt.Api/Models/Baking/StakerData.cs index 923d72835..c9a253b9f 100644 --- a/Mvkt.Api/Models/DelegationInfo/StakerData.cs +++ b/Mvkt.Api/Models/Baking/StakerData.cs @@ -16,7 +16,7 @@ public class StakerData public string BakerAlias { get; set; } /// - /// Staked balance (mutez) at this baker + /// Staked balance at this baker /// public long StakedBalance { get; set; } } diff --git a/Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs b/Mvkt.Api/Models/Baking/StakingRewardEvent.cs similarity index 90% rename from Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs rename to Mvkt.Api/Models/Baking/StakingRewardEvent.cs index 8f99745e2..03db986c0 100644 --- a/Mvkt.Api/Models/DelegationInfo/StakingRewardEvent.cs +++ b/Mvkt.Api/Models/Baking/StakingRewardEvent.cs @@ -1,7 +1,7 @@ namespace Mvkt.Api.Models { /// - /// A single staking reward event + /// A single staking reward event. /// public class StakingRewardEvent { @@ -11,7 +11,7 @@ public class StakingRewardEvent public int Cycle { get; set; } /// - /// Reward amount (micro tez) + /// Reward amount /// public long Amount { get; set; } diff --git a/Mvkt.Api/Models/Baking/StakingUpdate.cs b/Mvkt.Api/Models/Baking/StakingUpdate.cs index d2faa1c84..0d0e6f7e3 100644 --- a/Mvkt.Api/Models/Baking/StakingUpdate.cs +++ b/Mvkt.Api/Models/Baking/StakingUpdate.cs @@ -43,7 +43,7 @@ public class StakingUpdate public string Type { get; set; } /// - /// Amount (mutez). + /// Amount. /// public long Amount { get; set; } diff --git a/Mvkt.Api/Models/Baking/UnstakeRequest.cs b/Mvkt.Api/Models/Baking/UnstakeRequest.cs index da640f1d9..fdc50b8fd 100644 --- a/Mvkt.Api/Models/Baking/UnstakeRequest.cs +++ b/Mvkt.Api/Models/Baking/UnstakeRequest.cs @@ -25,22 +25,22 @@ public class UnstakeRequest public Alias Staker { get; set; } /// - /// Initially requested amount (mutez). + /// Initially requested amount. /// public long RequestedAmount { get; set; } /// - /// Amount that was restaked back (mutez). + /// Amount that was restaked back. /// public long RestakedAmount { get; set; } /// - /// Finalized amount (mutez). + /// Finalized amount. /// public long FinalizedAmount { get; set; } /// - /// Slashed amount (mutez). + /// Slashed amount. /// public long SlashedAmount { get; set; } diff --git a/Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs b/Mvkt.Api/Models/Baking/ValidatorRewardSummary.cs similarity index 82% rename from Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs rename to Mvkt.Api/Models/Baking/ValidatorRewardSummary.cs index 32bfcc252..b2448522f 100644 --- a/Mvkt.Api/Models/DelegationInfo/ValidatorRewardSummary.cs +++ b/Mvkt.Api/Models/Baking/ValidatorRewardSummary.cs @@ -8,10 +8,10 @@ public class ValidatorRewardSummary /// /// Validator information /// - public ValidatorInfo Validator { get; set; } + public Alias Validator { get; set; } /// - /// Total rewards from this validator (micro tez) + /// Total rewards from this validator /// public long TotalRewards { get; set; } @@ -31,12 +31,12 @@ public class ValidatorRewardSummary public int LastCycle { get; set; } /// - /// Total staking rewards (auto-restaked) (micro tez) + /// Total staking rewards (auto-restaked) /// public long TotalStakingRewards { get; set; } /// - /// Total delegation rewards (paid out) (micro tez) + /// Total delegation rewards (paid out) /// public long TotalDelegationRewards { get; set; } diff --git a/Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs b/Mvkt.Api/Models/Baking/ValidatorRewardTracking.cs similarity index 81% rename from Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs rename to Mvkt.Api/Models/Baking/ValidatorRewardTracking.cs index e6af610b9..cab23c354 100644 --- a/Mvkt.Api/Models/DelegationInfo/ValidatorRewardTracking.cs +++ b/Mvkt.Api/Models/Baking/ValidatorRewardTracking.cs @@ -16,47 +16,47 @@ public class ValidatorRewardTracking public string Alias { get; set; } /// - /// Expected staking rewards (micro tez) + /// Expected staking rewards /// public long ExpectedStakingRewards { get; set; } /// - /// Expected delegation rewards (micro tez) + /// Expected delegation rewards /// public long ExpectedDelegationRewards { get; set; } /// - /// Expected total rewards (micro tez) + /// Expected total rewards /// public long ExpectedTotalGross { get; set; } /// - /// Actual delegation payouts received (micro tez) + /// Actual delegation payouts received /// public long ActualDelegationPayouts { get; set; } /// - /// Actual staking rewards restaked (micro tez) + /// Actual staking rewards restaked /// public long ActualStakingRestaked { get; set; } /// - /// Actual total received (micro tez) + /// Actual total received /// public long ActualTotalReceived { get; set; } /// - /// Pending delegation rewards (micro tez) + /// Pending delegation rewards /// public long PendingDelegation { get; set; } /// - /// Pending staking rewards (micro tez) + /// Pending staking rewards /// public long PendingStaking { get; set; } /// - /// Total pending rewards (micro tez) + /// Total pending rewards /// public long PendingTotal { get; set; } @@ -86,9 +86,9 @@ public class ValidatorRewardTracking public int LastCycle { get; set; } /// - /// Staking rewards by cycle + /// Staking rewards by cycle (validator is the one being tracked by this record) /// - public List StakingRewardsByCycle { get; set; } + public List StakingRewardsByCycle { get; set; } /// /// Number of staking restake events diff --git a/Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs b/Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs deleted file mode 100644 index 1a2a7fb58..000000000 --- a/Mvkt.Api/Models/DelegationInfo/CycleStakingReward.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Mvkt.Api.Models -{ - /// - /// Staking reward for a specific cycle - /// - public class CycleStakingReward - { - /// - /// Cycle number - /// - public int Cycle { get; set; } - - /// - /// Reward amount (micro tez) - /// - public long Amount { get; set; } - - /// - /// Timestamp of the reward (ISO 8601) - /// - public DateTime? Timestamp { get; set; } - } -} diff --git a/Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs b/Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs deleted file mode 100644 index 3fde7857a..000000000 --- a/Mvkt.Api/Models/DelegationInfo/ValidatorInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Mvkt.Api.Models -{ - /// - /// Basic validator information - /// - public class ValidatorInfo - { - /// - /// Validator address - /// - public string Address { get; set; } - - /// - /// Validator alias/name - /// - public string Alias { get; set; } - } -} diff --git a/Mvkt.Api/Services/Delegation/DelegationSummaryService.cs b/Mvkt.Api/Services/Delegation/DelegationSummaryService.cs index 7bc4637bd..3b1975273 100644 --- a/Mvkt.Api/Services/Delegation/DelegationSummaryService.cs +++ b/Mvkt.Api/Services/Delegation/DelegationSummaryService.cs @@ -67,7 +67,7 @@ public async Task GetDelegationInfoAsync(string address, bool le int accountId = 0; bool isDelegating = false; - ValidatorInfo delegatedValidator = null; + Alias delegatedValidator = null; long delegatedBalance = 0; DateTime? delegationTime = null; long stakedBalance = 0; @@ -78,7 +78,7 @@ public async Task GetDelegationInfoAsync(string address, bool le accountId = user.Id; isDelegating = user.Delegate != null; delegatedValidator = user.Delegate != null - ? new ValidatorInfo { Address = user.Delegate.Address, Alias = user.Delegate.Alias } + ? new Alias { Address = user.Delegate.Address, Name = user.Delegate.Alias } : null; delegatedBalance = user.Balance; delegationTime = user.DelegationTime; @@ -97,7 +97,7 @@ public async Task GetDelegationInfoAsync(string address, bool le accountId = contract.Id; isDelegating = contract.Delegate != null; delegatedValidator = contract.Delegate != null - ? new ValidatorInfo { Address = contract.Delegate.Address, Alias = contract.Delegate.Alias } + ? new Alias { Address = contract.Delegate.Address, Name = contract.Delegate.Alias } : null; delegatedBalance = contract.Balance; delegationTime = contract.DelegationTime; @@ -296,7 +296,7 @@ private async Task CalculateActualRewardsAsync( var cycles = cycleInfo?.Cycles ?? new List(); var alias = cycleInfo?.Alias ?? actualDelegation?.Alias ?? actualStaking?.Alias; - var stakingByCycle = actualStaking?.ByCycle ?? new List(); + var stakingByCycle = actualStaking?.ByCycle ?? new List(); var delegationCycles = cycleInfo?.DelegationByCycle ?? new List(); var sortedDelegationCycles = delegationCycles.Count > 0 ? delegationCycles.OrderBy(d => d.Cycle).ToList() @@ -405,23 +405,24 @@ private static AggregatedRewardsFromDelegators AggregateRewardsFromDelegatorRewa Amount = 0, Count = 0, Alias = reward.Baker?.Name, - ByCycle = new List() + ByCycle = new List() }; stakingRewardsByValidator[bakerAddress] = stakingData; } stakingData.Amount += stakingReward; stakingData.Count += 1; - stakingData.ByCycle.Add(new CycleStakingReward { Cycle = reward.Cycle, Amount = stakingReward, Timestamp = null }); - stakingRewardsRestaked += stakingReward; - stakingRestakeCount += 1; - allStakingRewardEvents.Add(new StakingRewardEvent + var stakingEvent = new StakingRewardEvent { Cycle = reward.Cycle, Amount = stakingReward, Timestamp = null, ValidatorAddress = bakerAddress, ValidatorAlias = reward.Baker?.Name - }); + }; + stakingData.ByCycle.Add(stakingEvent); + stakingRewardsRestaked += stakingReward; + stakingRestakeCount += 1; + allStakingRewardEvents.Add(stakingEvent); } var validatorDelegationRewards = reward.BlockRewardsDelegated + reward.EndorsementRewardsDelegated; @@ -629,7 +630,7 @@ private static DelegationInfo BuildEmptyDelegationInfo() private DelegationSummary BuildDelegationSummary( Account account, bool isDelegating, - ValidatorInfo delegatedValidator, + Alias delegatedValidator, long delegatedBalance, DateTime? delegationTime, List stakingInfo, @@ -642,13 +643,13 @@ private DelegationSummary BuildDelegationSummary( var stakedValidators = stakingInfo.Select(s => new StakedValidatorInfo { - Baker = new ValidatorInfo { Address = s.BakerAddress, Alias = s.BakerAlias }, + Baker = new Alias { Address = s.BakerAddress, Name = s.BakerAlias }, StakedBalance = s.StakedBalance }).ToList(); var rewardsByValidator = actualRewards.ByValidator.Select(v => new ValidatorRewardSummary { - Validator = new ValidatorInfo { Address = v.Address, Alias = v.Alias }, + Validator = new Alias { Address = v.Address, Name = v.Alias }, TotalRewards = v.ActualTotalReceived, CycleCount = v.CycleCount, FirstCycle = v.FirstCycle, @@ -684,7 +685,7 @@ private DelegationSummary BuildDelegationSummary( Rewards = stakingReward + delegationReward, DelegatedBalance = r.DelegatedBalance, StakedBalance = r.StakedBalance, - Validator = new ValidatorInfo { Address = r.Baker?.Address, Alias = r.Baker?.Name }, + Validator = new Alias { Address = r.Baker?.Address, Name = r.Baker?.Name }, StakingRewards = stakingReward, DelegationRewards = delegationReward }; @@ -807,7 +808,7 @@ private class ValidatorStakingData public long Amount { get; set; } public int Count { get; set; } public string Alias { get; set; } - public List ByCycle { get; set; } + public List ByCycle { get; set; } } private class ValidatorExpectedData