Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions Mvkt.Api.Tests/Api/TestDelegationSummaryQueries.cs
Original file line number Diff line number Diff line change
@@ -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<SettingsFixture>
{
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);
}
}
}
}
}
}
11 changes: 8 additions & 3 deletions Mvkt.Api/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using Mvkt.Api.Models;
Expand All @@ -18,7 +18,12 @@ public class AccountsController : ControllerBase
readonly StateCache State;
readonly ResponseCacheService ResponseCache;

public AccountsController(AccountRepository accounts, BalanceHistoryRepository history, ReportRepository reports, StateCache state, ResponseCacheService responseCache)
public AccountsController(
AccountRepository accounts,
BalanceHistoryRepository history,
ReportRepository reports,
StateCache state,
ResponseCacheService responseCache)
{
Accounts = accounts;
History = history;
Expand Down Expand Up @@ -175,7 +180,7 @@ public async Task<ActionResult<Account>> 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);
Expand Down
41 changes: 39 additions & 2 deletions Mvkt.Api/Controllers/RewardsController.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -171,6 +181,33 @@ public async Task<DelegatorRewards> GetDelegatorRewardsByCycle([Required][Addres
return (await Rewards.GetDelegatorRewards(address, cycle, null, null, 100, quote)).FirstOrDefault();
}

/// <summary>
/// Get delegation and staking information for a delegator
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="address">Delegator address</param>
/// <param name="legacy">If `true` (by default), the account is resolved using legacy semantics. This is a part of a deprecation mechanism, allowing smooth migration.</param>
/// <returns></returns>
[HttpGet("delegators/{address}/summary")]
public async Task<ActionResult<DelegationInfo>> 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);
}

/// <summary>
/// Get reward split
/// </summary>
Expand Down
83 changes: 83 additions & 0 deletions Mvkt.Api/Models/Baking/ActualRewards.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace Mvkt.Api.Models
{
/// <summary>
/// Actual rewards received compared to expected rewards
/// </summary>
public class ActualRewards
{
/// <summary>
/// Total actual rewards received
/// </summary>
public long TotalActualRewards { get; set; }

/// <summary>
/// Delegation payouts received
/// </summary>
public long DelegationPayouts { get; set; }

/// <summary>
/// Number of delegation payout transactions
/// </summary>
public int DelegationPayoutCount { get; set; }

/// <summary>
/// Staking rewards auto-restaked
/// </summary>
public long StakingRewardsRestaked { get; set; }

/// <summary>
/// Number of staking restake events
/// </summary>
public int StakingRestakeCount { get; set; }

/// <summary>
/// Expected delegation rewards
/// </summary>
public long ExpectedDelegationRewards { get; set; }

/// <summary>
/// Expected staking rewards
/// </summary>
public long ExpectedStakingRewards { get; set; }

/// <summary>
/// Expected total rewards
/// </summary>
public long ExpectedTotalRewards { get; set; }

/// <summary>
/// Pending delegation rewards not yet paid
/// </summary>
public long PendingDelegation { get; set; }

/// <summary>
/// Pending staking rewards
/// </summary>
public long PendingStaking { get; set; }

/// <summary>
/// Total pending rewards
/// </summary>
public long PendingTotal { get; set; }

/// <summary>
/// Reward tracking per validator
/// </summary>
public List<ValidatorRewardTracking> ByValidator { get; set; }

/// <summary>
/// All staking reward events
/// </summary>
public List<StakingRewardEvent> AllStakingRewardEvents { get; set; }

/// <summary>
/// First reward date (ISO 8601)
/// </summary>
public DateTime? FirstRewardDate { get; set; }

/// <summary>
/// Last reward date (ISO 8601)
/// </summary>
public DateTime? LastRewardDate { get; set; }
}
}
Loading
Loading