diff --git a/src/Nethermind/Chains/xdc.json b/src/Nethermind/Chains/xdc.json index d96bdac61bc..d94865903f1 100644 --- a/src/Nethermind/Chains/xdc.json +++ b/src/Nethermind/Chains/xdc.json @@ -71,7 +71,8 @@ "eip150Hash": "0x0000000000000000000000000000000000000000000000000000000000000000", "eip155Block": 3, "eip158Block": 3, - "byzantiumBlock": 4 + "byzantiumBlock": 4, + "blockSignerContract": "0x0000000000000000000000000000000000000089" }, "genesis": { "nonce": "0x0", diff --git a/src/Nethermind/Nethermind.Xdc.Test/Helpers/TransactionBuilderXdcExtensions.cs b/src/Nethermind/Nethermind.Xdc.Test/Helpers/TransactionBuilderXdcExtensions.cs new file mode 100644 index 00000000000..0de5fe077c0 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc.Test/Helpers/TransactionBuilderXdcExtensions.cs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test.Builders; +using Nethermind.Xdc.Spec; + +namespace Nethermind.Xdc.Test.Helpers; + +public static class TransactionBuilderXdcExtensions +{ + // function sign(uint256 _blockNumber, bytes32 _blockHash) + // selector = 0xe341eaa4 + private static ReadOnlySpan SignSelector => new byte[] { 0xE3, 0x41, 0xEA, 0xA4 }; + + /// Sets 'To' to the XDC block-signer contract from the spec. + public static TransactionBuilder ToBlockSignerContract( + this TransactionBuilder b, IXdcReleaseSpec spec) + => b.To(spec.BlockSignerContract); + + /// + /// Appends ABI-encoded calldata for sign(uint256 _blockNumber, bytes32 _blockHash). + /// Calldata = 4-byte selector + 32-byte big-endian uint + 32-byte bytes32 (68 bytes total). + /// + public static TransactionBuilder WithXdcSigningData( + this TransactionBuilder b, long blockNumber, Hash256 blockHash) + => b.WithData(CreateSigningCalldata(blockNumber, blockHash)); + + private static byte[] CreateSigningCalldata(long blockNumber, Hash256 blockHash) + { + Span data = stackalloc byte[68]; // 4 + 32 + 32 + + // 0..3: selector + SignSelector.CopyTo(data); + + // 4..35: uint256 blockNumber (big-endian, right-aligned in 32 bytes) + var be = BitConverter.GetBytes((ulong)blockNumber); + if (BitConverter.IsLittleEndian) Array.Reverse(be); + // last 8 bytes of that 32 are the ulong + for (int i = 0; i < 8; i++) data[4 + 24 + i] = be[i]; + + // 36..67: bytes32 blockHash + blockHash.Bytes.CopyTo(data.Slice(36, 32)); + + return data.ToArray(); + } +} diff --git a/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcTestBlockchain.cs b/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcTestBlockchain.cs index 33f5e224e9d..496e356ba5a 100644 --- a/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcTestBlockchain.cs +++ b/src/Nethermind/Nethermind.Xdc.Test/Helpers/XdcTestBlockchain.cs @@ -10,6 +10,7 @@ using Nethermind.Consensus.Comparers; using Nethermind.Consensus.Processing; using Nethermind.Consensus.Producers; +using Nethermind.Consensus.Rewards; using Nethermind.Core; using Nethermind.Core.Specs; using Nethermind.Core.Test.Blockchain; @@ -55,7 +56,7 @@ public static async Task Create(int blocksToAdd = 3, bool use if (testConfiguration.SuggestGenesisOnStart) { - // The block added event is not waited by genesis, but its needed to wait here so that `AddBlock` wait correctly. + // The block added event is not waited by genesis, but it's needed to wait here so that `AddBlock` waits correctly. Task newBlockWaiter = chain.BlockTree.WaitForNewBlock(chain.CancellationToken); chain.MainProcessingContext.GenesisLoader.Load(); await newBlockWaiter; @@ -173,7 +174,16 @@ protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder, .AddSingleton() .AddSingleton() .AddSingleton() - .AddScoped() + .AddScoped(ctx => + new XdcTestGenesisBuilder( + ctx.Resolve(), + ctx.Resolve(), + ctx.Resolve(), + ctx.Resolve>().ToArray(), + ctx.Resolve(), + MasterNodeCandidates + ) + ) .AddSingleton() .AddSingleton((ctx) => new CandidateContainer(MasterNodeCandidates)) .AddSingleton(ctx => @@ -185,6 +195,7 @@ protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder, }) .AddSingleton((_) => BlockProducer) //.AddSingleton((_) => BlockProducerRunner) + .AddSingleton() .AddSingleton() .AddSingleton(new ProcessExitSource(TestContext.CurrentContext.CancellationToken)) @@ -218,6 +229,7 @@ private IXdcReleaseSpec WrapReleaseSpec(IReleaseSpec spec) xdcSpec.LimitPenaltyEpoch = 2; xdcSpec.MinimumSigningTx = 1; xdcSpec.GasLimitBoundDivisor = 1024; + xdcSpec.BlockSignerContract = new Address("0x0000000000000000000000000000000000000089"); V2ConfigParams[] v2ConfigParams = [ new V2ConfigParams { @@ -263,6 +275,8 @@ private IXdcReleaseSpec WrapReleaseSpec(IReleaseSpec spec) ]; xdcSpec.V2Configs = v2ConfigParams.ToList(); + xdcSpec.ValidateChainId = false; + xdcSpec.Reward = 5000; return xdcSpec; } @@ -302,7 +316,8 @@ private class XdcTestGenesisBuilder( IWorldState state, ISnapshotManager snapshotManager, IGenesisPostProcessor[] postProcessors, - Configuration testConfiguration + Configuration testConfiguration, + List masterNodeCandidates ) : IGenesisBuilder { public Block Build() @@ -311,6 +326,11 @@ public Block Build() state.CreateAccount(TestItem.AddressB, testConfiguration.AccountInitialValue); state.CreateAccount(TestItem.AddressC, testConfiguration.AccountInitialValue); + foreach (PrivateKey candidate in masterNodeCandidates) + { + state.CreateAccount(candidate.Address, testConfiguration.AccountInitialValue); + } + IXdcReleaseSpec? finalSpec = (IXdcReleaseSpec)specProvider.GetFinalSpec(); XdcBlockHeaderBuilder xdcBlockHeaderBuilder = new(); @@ -335,6 +355,11 @@ public Block Build() } } + private class NullRewardCalculator : IRewardCalculator + { + public BlockReward[] CalculateRewards(Block block) => Array.Empty(); + } + public void ChangeReleaseSpec(Action reconfigure) { reconfigure((XdcReleaseSpec)SpecProvider.GetXdcSpec((XdcBlockHeader)BlockTree.Head!.Header)); diff --git a/src/Nethermind/Nethermind.Xdc.Test/ModuleTests/RewardTests.cs b/src/Nethermind/Nethermind.Xdc.Test/ModuleTests/RewardTests.cs new file mode 100644 index 00000000000..2b1b8731708 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc.Test/ModuleTests/RewardTests.cs @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Nethermind.Consensus.Rewards; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Int256; +using Nethermind.Xdc.Contracts; +using Nethermind.Xdc.Spec; +using Nethermind.Xdc.Test.Helpers; +using Nethermind.Xdc.Types; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Xdc.Test.ModuleTests; + +public class RewardTests +{ + [Test] + public async Task TestHookRewardV2() + { + var chain = await XdcTestBlockchain.Create(); + var masternodeVotingContract = Substitute.For(); + var rc = new XdcRewardCalculator( + chain.EpochSwitchManager, + chain.SpecProvider, + chain.BlockTree, + masternodeVotingContract + ); + var head = (XdcBlockHeader)chain.BlockTree.Head!.Header; + IXdcReleaseSpec spec = chain.SpecProvider.GetXdcSpec(head, chain.XdcContext.CurrentRound); + var epochLength = spec.EpochLength; + + // Add blocks up to epochLength (E) + 15 and create a signing tx that will be inserted in the next block + await chain.AddBlocks(epochLength + 15 - 3); + var header915 = chain.BlockTree.Head!.Header as XdcBlockHeader; + Assert.That(header915, Is.Not.Null); + PrivateKey signer915 = GetSignerFromMasternodes(chain, header915, spec); + Address owner = signer915.Address; + masternodeVotingContract.GetCandidateOwner(Arg.Any(), signer915.Address).Returns(owner); + await chain.AddBlock(BuildSigningTx( + spec, + header915.Number, + header915.Hash ?? header915.CalculateHash().ToHash256(), + signer915)); + + // Add blocks up until 3E and evaluate rewards at this checkpoint + // Save block 3E - 15 for second part of the test + await chain.AddBlocks(2 * epochLength - 31); + var header2685 = chain.BlockTree.Head!.Header as XdcBlockHeader; + Assert.That(header2685, Is.Not.Null); + PrivateKey signer2685 = GetSignerFromMasternodes(chain, header2685, spec); + Address owner2 = signer2685.Address; + masternodeVotingContract.GetCandidateOwner(Arg.Any(), signer2685.Address).Returns(owner2); + // Continue adding blocks up until 3E + await chain.AddBlocks(15); + Block block2700 = chain.BlockTree.Head!; + var header2700 = block2700.Header as XdcBlockHeader; + Assert.That(header2700, Is.Not.Null); + + BlockReward[] rewardsAt2700 = rc.CalculateRewards(block2700); + + // Expect exactly 2 entries: one for the masternode owner and one for foundation + Address foundation = spec.FoundationWallet; + foundation.Should().NotBe(Address.Zero); + + Assert.That(rewardsAt2700, Has.Length.EqualTo(2)); + rewardsAt2700.Length.Should().Be(2); + + UInt256 total = rewardsAt2700.Aggregate(UInt256.Zero, (acc, r) => acc + r.Value); + UInt256 ownerReward = rewardsAt2700.Single(r => r.Address == owner).Value; + UInt256 foundationReward = rewardsAt2700.Single(r => r.Address == foundation).Value; + + // Check 90/10 split + Assert.That(foundationReward, Is.EqualTo(total / 10)); + Assert.That(ownerReward, Is.EqualTo(total * 90 / 100)); + + // === Second part of the test: signing hash in a different epoch still counts === + + Transaction signingTx2 = BuildSigningTx( + spec, + header2685.Number, + header2685.Hash!, + signer2685); + + // Place signingTx2 in block 3E + 16 (different epoch than the signed block) + await chain.AddBlocks(15); + await chain.AddBlock(signingTx2); + + // Add blocks up until 4E and check rewards + await chain.AddBlocks(epochLength - 16); + Block block3600 = chain.BlockTree.Head!; + var header3600 = block3600.Header as XdcBlockHeader; + Assert.That(header3600, Is.Not.Null); + BlockReward[] rewardsAt3600 = rc.CalculateRewards(block3600); + + // Same expectations: exactly two outputs with 90/10 split + // Since this only counts signing txs from 2E to 3E, only signingTx2 should get counted + Assert.That(rewardsAt3600, Has.Length.EqualTo(2)); + UInt256 total2 = rewardsAt3600.Aggregate(UInt256.Zero, (acc, r) => acc + r.Value); + UInt256 ownerReward2 = rewardsAt3600.Single(r => r.Address == owner2).Value; + UInt256 foundationReward2 = rewardsAt3600.Single(r => r.Address == foundation).Value; + + Assert.That(foundationReward2, Is.EqualTo(total2 / 10)); + Assert.That(ownerReward2, Is.EqualTo(total2 * 90 / 100)); + + // === Third part of the test: if no signing tx, reward should be empty === + + // Add blocks up to 5E and check rewards + await chain.AddBlocks(epochLength); + Block block4500 = chain.BlockTree.Head!; + BlockReward[] rewardsAt4500 = rc.CalculateRewards(block4500); + // If no valid signing txs were counted for that epoch, expect no rewards. + rewardsAt4500.Should().BeEmpty(); + } + + [Test] + public async Task TestHookRewardV2SplitReward() + { + var chain = await XdcTestBlockchain.Create(); + var masternodeVotingContract = Substitute.For(); + var rc = new XdcRewardCalculator( + chain.EpochSwitchManager, + chain.SpecProvider, + chain.BlockTree, + masternodeVotingContract + ); + + var head = (XdcBlockHeader)chain.BlockTree.Head!.Header; + IXdcReleaseSpec spec = chain.SpecProvider.GetXdcSpec(head, chain.XdcContext.CurrentRound); + var epochLength = spec.EpochLength; + + // === Layout (mirrors Go test intent) === + // - Insert 1 signing tx for header (E + 15) signed by signerA into block (E + 16) + // - Insert 2 signing txs (one for header (E + 15), one for header (2E - 15)) signed by signerB into block (2E - 1) + // - Verify: rewards at (3E) split 1:2 between A:B with 90/10 owner/foundation + + // Move to block (E + 15) + await chain.AddBlocks(epochLength + 15 - 3); + var header915 = (XdcBlockHeader)chain.BlockTree.Head!.Header; + + EpochSwitchInfo? epochInfo = chain.EpochSwitchManager.GetEpochSwitchInfo(header915); + Assert.That(epochInfo, Is.Not.Null); + PrivateKey[] masternodes = chain.TakeRandomMasterNodes(spec, epochInfo); + PrivateKey signerA = masternodes.First(); + PrivateKey signerB = masternodes.Last(); + Address ownerA = signerA.Address; + Address ownerB = signerB.Address; + masternodeVotingContract.GetCandidateOwner(Arg.Any(), signerA.Address).Returns(ownerA); + masternodeVotingContract.GetCandidateOwner(Arg.Any(), signerB.Address).Returns(ownerB); + + // Insert 1 signing tx for header (E + 15) in block (E + 16) + Transaction txA = BuildSigningTx( + spec, + header915.Number, + header915.Hash ?? header915.CalculateHash().ToHash256(), + signerA); + await chain.AddBlock(txA); // advances to (E + 16) + + // Move to block (2E - 15) to capture that header as well + var currentNumber = chain.BlockTree.Head!.Number; + await chain.AddBlocks(epochLength - 31); + var header1785 = (XdcBlockHeader)chain.BlockTree.Head!.Header; + + // Prepare two signing txs signed by signerB: + // - for header (E + 15) + // - for header (2E - 15) + Transaction txB1 = BuildSigningTx( + spec, + header915.Number, + header915.Hash!, + signerB); + + Transaction txB2 = BuildSigningTx( + spec, + header1785.Number, + header1785.Hash!, + signerB, + 1); + + // Advance to (2E - 2), then add a block with both signerB txs to be at (2E - 1) + await chain.AddBlocks(13); + await chain.AddBlock(txB1, txB2); // now at (2E - 1) + + // Rewards at (3E) should exist with split 1:2 across A:B and 90/10 owner/foundation + await chain.AddBlocks(epochLength + 1); // from (2E - 1) to (3E) + Block block2700 = chain.BlockTree.Head!; + BlockReward[] rewards = rc.CalculateRewards(block2700); + + Address foundation = spec.FoundationWallet; + + // Expect exactly 3 entries: ownerA, ownerB, foundation. + Assert.That(rewards.Length, Is.EqualTo(3)); + + // Calculate exact rewards for each address + UInt256 totalRewards = (UInt256)spec.Reward * Unit.Ether; + UInt256 signerAReward = totalRewards / 3; + UInt256 ownerAReward = signerAReward * 90 / 100; + UInt256 foundationReward = signerAReward / 10; + UInt256 signerBReward = totalRewards * 2 / 3; + UInt256 ownerBReward = signerBReward * 90 / 100; + foundationReward += signerBReward / 10; + + foreach (BlockReward reward in rewards) + { + if (reward.Address == ownerA) Assert.That(reward.Value, Is.EqualTo(ownerAReward)); + if (reward.Address == ownerB) Assert.That(reward.Value, Is.EqualTo(ownerBReward)); + if (reward.Address == foundation) Assert.That(reward.Value, Is.EqualTo(foundationReward)); + } + } + + private static Transaction BuildSigningTx(IXdcReleaseSpec spec, long blockNumber, Hash256 blockHash, PrivateKey signer, long nonce = 0) + { + return Build.A.Transaction + .WithChainId(0) + .WithNonce((UInt256)nonce) + .WithGasLimit(200000) + .WithXdcSigningData(blockNumber, blockHash) + .ToBlockSignerContract(spec) + .SignedAndResolved(signer) + .TestObject; + } + + private static PrivateKey GetSignerFromMasternodes(XdcTestBlockchain chain, XdcBlockHeader header, IXdcReleaseSpec spec) + { + EpochSwitchInfo? epochInfo = chain.EpochSwitchManager.GetEpochSwitchInfo(header); + Assert.That(epochInfo, Is.Not.Null); + return chain.TakeRandomMasterNodes(spec, epochInfo).First(); + } +} diff --git a/src/Nethermind/Nethermind.Xdc/Contracts/IMasternodeVotingContract.cs b/src/Nethermind/Nethermind.Xdc/Contracts/IMasternodeVotingContract.cs index bb1945b2780..520f16b8db7 100644 --- a/src/Nethermind/Nethermind.Xdc/Contracts/IMasternodeVotingContract.cs +++ b/src/Nethermind/Nethermind.Xdc/Contracts/IMasternodeVotingContract.cs @@ -6,8 +6,9 @@ namespace Nethermind.Xdc.Contracts; -internal interface IMasternodeVotingContract +public interface IMasternodeVotingContract { Address[] GetCandidates(BlockHeader blockHeader); UInt256 GetCandidateStake(BlockHeader blockHeader, Address candidate); + Address GetCandidateOwner(BlockHeader blockHeader, Address candidate); } diff --git a/src/Nethermind/Nethermind.Xdc/Contracts/MasternodeVotingContract.cs b/src/Nethermind/Nethermind.Xdc/Contracts/MasternodeVotingContract.cs index 8d3f7e6038f..4453087afd5 100644 --- a/src/Nethermind/Nethermind.Xdc/Contracts/MasternodeVotingContract.cs +++ b/src/Nethermind/Nethermind.Xdc/Contracts/MasternodeVotingContract.cs @@ -7,10 +7,8 @@ using Nethermind.Blockchain.Contracts.Json; using Nethermind.Core; using Nethermind.Core.Crypto; -using Nethermind.Core.Specs; using Nethermind.Evm.State; using Nethermind.Int256; -using Nethermind.State; using System; namespace Nethermind.Xdc.Contracts; @@ -47,6 +45,16 @@ public UInt256 GetCandidateStake(BlockHeader blockHeader, Address candidate) return (UInt256)result[0]!; } + public Address GetCandidateOwner(BlockHeader blockHeader, Address candidate) + { + CallInfo callInfo = new CallInfo(blockHeader, "getCandidateOwner", Address.SystemUser, candidate); + object[] result = _constant.Call(callInfo); + if (result.Length != 1) + throw new InvalidOperationException("Expected 'getCandidateOwner' to return exactly one result."); + + return (Address)result[0]!; + } + public Address[] GetCandidates(BlockHeader blockHeader) { CallInfo callInfo = new CallInfo(blockHeader, "getCandidates", Address.SystemUser); diff --git a/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecBasedSpecProvider.cs b/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecBasedSpecProvider.cs index bf343c85384..a6038f2f3c9 100644 --- a/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecBasedSpecProvider.cs +++ b/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecBasedSpecProvider.cs @@ -22,6 +22,8 @@ protected override ReleaseSpec CreateReleaseSpec(ChainSpec chainSpec, long relea releaseSpec.SwitchEpoch = chainSpecEngineParameters.SwitchEpoch; releaseSpec.SwitchBlock = chainSpecEngineParameters.SwitchBlock; releaseSpec.V2Configs = chainSpecEngineParameters.V2Configs; + releaseSpec.FoundationWallet = chainSpecEngineParameters.FoundationWalletAddr; + releaseSpec.Reward = chainSpecEngineParameters.Reward; releaseSpec.ApplyV2Config(0); diff --git a/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs b/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs index bf0b7453dff..bb3f7bdd3db 100644 --- a/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs +++ b/src/Nethermind/Nethermind.Xdc/Spec/XdcReleaseSpec.cs @@ -12,6 +12,7 @@ public class XdcReleaseSpec : ReleaseSpec, IXdcReleaseSpec { public int EpochLength { get; set; } public int Gap { get; set; } + public long Reward { get; set; } public int SwitchEpoch { get; set; } public long SwitchBlock { get; set; } public int MaxMasternodes { get; set; } // v2 max masternodes @@ -31,6 +32,8 @@ public class XdcReleaseSpec : ReleaseSpec, IXdcReleaseSpec public List V2Configs { get; set; } = new List(); public Address[] GenesisMasterNodes { get; set; } + public Address FoundationWallet { get; set; } + public Address BlockSignerContract { get; set; } public void ApplyV2Config(ulong round) { @@ -79,6 +82,7 @@ public interface IXdcReleaseSpec : IReleaseSpec { public int EpochLength { get; } public int Gap { get; } + public long Reward { get; } public int SwitchEpoch { get; set; } public long SwitchBlock { get; set; } public int MaxMasternodes { get; set; } // v2 max masternodes @@ -97,5 +101,8 @@ public interface IXdcReleaseSpec : IReleaseSpec public int MinimumSigningTx { get; set; } // Signing txs that a node needs to produce to get out of penalty, after `LimitPenaltyEpoch` public List V2Configs { get; set; } Address[] GenesisMasterNodes { get; set; } + Address FoundationWallet { get; set; } + Address BlockSignerContract { get; set; } + public void ApplyV2Config(ulong round); } diff --git a/src/Nethermind/Nethermind.Xdc/XdcHotStuff.cs b/src/Nethermind/Nethermind.Xdc/XdcHotStuff.cs index c17b6d1dc3a..022bc2ce2d3 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcHotStuff.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcHotStuff.cs @@ -11,8 +11,11 @@ using Nethermind.Xdc.Spec; using Nethermind.Xdc.Types; using System; +using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; +using Nethermind.Consensus.Rewards; +using Nethermind.Core.Crypto; namespace Nethermind.Xdc { @@ -34,6 +37,8 @@ internal class XdcHotStuff : IBlockProducerRunner private readonly ITimeoutTimer _timeoutTimer; private readonly IProcessExitSource _processExit; private readonly ILogger _logger; + private readonly IRewardCalculator _rewardCalculator; + private readonly ConcurrentDictionary _rewardsDone = new(); private CancellationTokenSource? _cancellationTokenSource; private Task? _runTask; @@ -58,7 +63,8 @@ public XdcHotStuff( ISigner signer, ITimeoutTimer timeoutTimer, IProcessExitSource processExit, - ILogManager logManager) + ILogManager logManager, + IRewardCalculator rewardCalculator) { _blockTree = blockTree ?? throw new ArgumentNullException(nameof(blockTree)); _xdcContext = xdcContext; @@ -71,6 +77,7 @@ public XdcHotStuff( _signer = signer ?? throw new ArgumentNullException(nameof(signer)); _timeoutTimer = timeoutTimer; _processExit = processExit; + _rewardCalculator = rewardCalculator ?? throw new ArgumentNullException(nameof(rewardCalculator)); _logger = logManager?.GetClassLogger() ?? throw new ArgumentNullException(nameof(logManager)); _lastActivityTime = DateTime.UtcNow; @@ -367,10 +374,40 @@ private void OnNewHeadBlock(object? sender, BlockEventArgs e) //TODO This should probably trigger a sync } + TryRunRewardHookAsync(e.Block, xdcHead); + // Signal new round _lastActivityTime = DateTime.UtcNow; } + private void TryRunRewardHookAsync(Block block, XdcBlockHeader header) + { + // Only run on epoch-switch blocks + if (!_epochSwitchManager.IsEpochSwitchAtBlock(header)) return; + + // Only run once per block hash + if (!_rewardsDone.TryAdd(header.Hash!, 0)) return; + + _ = Task.Run(() => + { + try + { + BlockReward[] rewards = _rewardCalculator.CalculateRewards(block); + + //TODO Here we should save the rewards, perhaps in DB? + if (rewards.Length == 0) + _logger.Info($"[Rewards] Epoch checkpoint #{block.Number} => no rewards."); + else + _logger.Info($"[Rewards] Epoch checkpoint #{block.Number} => {rewards.Length} reward entries."); + } + catch (Exception ex) + { + _logger.Error($"[Rewards] Failed at checkpoint #{block.Number}", ex); + //TODO Allow retry if desired? _rewardsDone.TryRemove(header.Hash!, out _); + } + }, _cancellationTokenSource?.Token ?? CancellationToken.None); + } + /// /// Check if the current node is the leader for the given round. /// Uses epoch switch manager and spec to determine leader via round-robin rotation. diff --git a/src/Nethermind/Nethermind.Xdc/XdcRewardCalculator.cs b/src/Nethermind/Nethermind.Xdc/XdcRewardCalculator.cs new file mode 100644 index 00000000000..1ad387678d4 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc/XdcRewardCalculator.cs @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Blockchain; +using Nethermind.Consensus.Rewards; +using Nethermind.Core; +using Nethermind.Core.Caching; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Int256; +using Nethermind.Xdc.Spec; +using System; +using System.Collections.Generic; +using System.Linq; +using Nethermind.Crypto; +using Nethermind.Xdc.Contracts; + +namespace Nethermind.Xdc +{ + /// + /// Reward model (current mainnet): + /// - Rewards are paid only at epoch checkpoints (number % EpochLength == 0). + /// - For now we **ignore** TIPUpgradeReward behavior because on mainnet + /// the upgrade activation is set far in the future (effectively “not active”). + /// When TIPUpgradeReward activates, protector/observer beneficiaries must be added. + /// - Current split implemented here: 90% to masternode owner, 10% to foundation. + /// + public class XdcRewardCalculator( + IEpochSwitchManager epochSwitchManager, + ISpecProvider specProvider, + IBlockTree blockTree, + IMasternodeVotingContract masternodeVotingContract) : IRewardCalculator + { + private LruCache _signingTxsCache = new(9000, "XDC Signing Txs Cache"); + private const long BlocksPerYear = 15768000; + // XDC rule: signing transactions are sampled/merged every N blocks (N=15 on XDC). + // Only block numbers that are multiples of MergeSignRange are considered when tallying signers. + private const long MergeSignRange = 15; + private static readonly EthereumEcdsa _ethereumEcdsa = new(0); + + /// + /// Calculates block rewards according to XDPoS consensus rules. + /// + /// For XDPoS, rewards are only distributed at epoch checkpoints (blocks where number % 900 == 0). + /// At these checkpoints, rewards are calculated based on masternode signature counts during + /// the previous epoch and distributed according to the 90/10 split model. + /// + /// The block to calculate rewards for + /// Array of BlockReward objects for all reward recipients + public BlockReward[] CalculateRewards(Block block) + { + if (block is null) + throw new ArgumentNullException(nameof(block)); + if (block.Header is not XdcBlockHeader xdcHeader) + throw new InvalidOperationException("Only supports XDC headers"); + + var rewards = new List(); + var number = xdcHeader.Number; + IXdcReleaseSpec spec = specProvider.GetXdcSpec(xdcHeader, xdcHeader.ExtraConsensusData.BlockRound); + if (number == spec.SwitchBlock + 1) return rewards.ToArray(); + + Address foundationWalletAddr = spec.FoundationWallet; + if (foundationWalletAddr == Address.Zero) throw new InvalidOperationException("Foundation wallet address cannot be empty"); + + var (signers, count) = GetSigningTxCount(number, xdcHeader, spec); + + UInt256 chainReward = (UInt256)spec.Reward * Unit.Ether; + Dictionary rewardSigners = CalculateRewardForSigners(chainReward, signers, count); + + UInt256 totalFoundationWalletReward = UInt256.Zero; + foreach (var (signer, reward) in rewardSigners) + { + (BlockReward holderReward, UInt256 foundationWalletReward) = DistributeRewards(signer, reward, xdcHeader); + totalFoundationWalletReward += foundationWalletReward; + rewards.Add(holderReward); + } + if (totalFoundationWalletReward > UInt256.Zero) rewards.Add(new BlockReward(foundationWalletAddr, totalFoundationWalletReward)); + return rewards.ToArray(); + } + + private (Dictionary Signers, long Count) GetSigningTxCount(long number, XdcBlockHeader header, IXdcReleaseSpec spec) + { + long signEpochCount = 1, rewardEpochCount = 2, epochCount = 0, endBlockNumber = 0, startBlockNumber = 0, signingCount = 0; + var signers = new Dictionary(); + var blockNumberToHash = new Dictionary(); + var hashToSigningAddress = new Dictionary>(); + var masternodes = new HashSet
(); + + if (number == 0) return (signers, signingCount); + XdcBlockHeader h = header; + for (long i = number - 1; i >= 0; i--) + { + Hash256 parentHash = h.ParentHash; + h = blockTree.FindHeader(parentHash!, i) as XdcBlockHeader; + if (h == null) throw new InvalidOperationException($"Header with hash {parentHash} not found"); + if (epochSwitchManager.IsEpochSwitchAtBlock(h) && i != spec.SwitchBlock + 1) + { + epochCount++; + if (epochCount == signEpochCount) endBlockNumber = i; + if (epochCount == rewardEpochCount) + { + startBlockNumber = i + 1; + // Get masternodes from epoch switch header + masternodes = new HashSet
(h.ValidatorsAddress!); + // TIPUpgradeReward path (protector/observer selection) is currently ignored, + // because on mainnet the upgrade height is set to an effectively unreachable block. + // If/when that changes, we must compute protector/observer sets here. + break; + } + } + + blockNumberToHash[i] = h.Hash; + if (!_signingTxsCache.TryGet(h.Hash, out Transaction[] signingTxs)) + { + Block? block = blockTree.FindBlock(i); + if (block == null) throw new InvalidOperationException($"Block with number {i} not found"); + Transaction[] txs = block.Transactions; + signingTxs = CacheSigningTxs(h.Hash!, txs, spec); + } + + foreach (Transaction tx in signingTxs) + { + Hash256 blockHash = ExtractBlockHashFromSigningTxData(tx.Data); + tx.SenderAddress ??= _ethereumEcdsa.RecoverAddress(tx); + if (!hashToSigningAddress.ContainsKey(blockHash)) + hashToSigningAddress[blockHash] = new HashSet
(); + hashToSigningAddress[blockHash].Add(tx.SenderAddress); + } + } + + // Only blocks at heights that are multiples of MergeSignRange are considered. + // Calculate start >= startBlockNumber so that start % MergeSignRange == 0 + long start = ((startBlockNumber + MergeSignRange - 1) / MergeSignRange) * MergeSignRange; + for (long i = start; i < endBlockNumber; i += MergeSignRange) + { + if (!blockNumberToHash.TryGetValue(i, out var blockHash)) continue; + if (!hashToSigningAddress.TryGetValue(blockHash, out var addrs)) continue; + foreach (Address addr in addrs) + { + if (!masternodes.Contains(addr)) continue; + if (!signers.ContainsKey(addr)) signers[addr] = 0; + signers[addr] += 1; + signingCount++; + } + } + return (signers, signingCount); + } + + private Transaction[] CacheSigningTxs(Hash256 hash, Transaction[] txs, IXdcReleaseSpec spec) + { + Transaction[] signingTxs = txs.Where(t => IsSigningTransaction(t, spec)).ToArray(); + _signingTxsCache.Set(hash, signingTxs); + return signingTxs; + } + + // Signing transaction ABI (Solidity): + // function sign(uint256 _blockNumber, bytes32 _blockHash) + // Calldata = 4-byte selector + 32-byte big-endian uint + 32-byte bytes32 = 68 bytes total. + private bool IsSigningTransaction(Transaction tx, IXdcReleaseSpec spec) + { + if (tx.To is null || tx.To != spec.BlockSignerContract) return false; + if (tx.Data.Length != 68) return false; + + return ExtractSelectorFromSigningTxData(tx.Data) == "0xe341eaa4"; + } + + private String ExtractSelectorFromSigningTxData(ReadOnlyMemory data) + { + ReadOnlySpan span = data.Span; + if (span.Length != 68) + throw new ArgumentException("Signing tx calldata must be exactly 68 bytes (4 + 32 + 32).", nameof(data)); + + // 0..3: selector + ReadOnlySpan selBytes = span.Slice(0, 4); + return "0x" + Convert.ToHexString(selBytes).ToLowerInvariant(); + } + + private Hash256 ExtractBlockHashFromSigningTxData(ReadOnlyMemory data) + { + ReadOnlySpan span = data.Span; + if (span.Length != 68) + throw new ArgumentException("Signing tx calldata must be exactly 68 bytes (4 + 32 + 32).", nameof(data)); + + // 36..67: bytes32 blockHash + ReadOnlySpan hashBytes = span.Slice(36, 32); + return new Hash256(hashBytes); + } + + private Dictionary CalculateRewardForSigners(UInt256 totalReward, + Dictionary signers, long totalSigningCount) + { + var rewardSigners = new Dictionary(); + foreach (var (signer, count) in signers) + { + UInt256 reward = CalculateProportionalReward(count, totalSigningCount, totalReward); + rewardSigners.Add(signer, reward); + } + return rewardSigners; + } + + /// + /// Calculates a proportional reward based on the number of signatures. + /// Uses UInt256 arithmetic to maintain precision with large Wei values. + /// + /// Formula: (signatureCount / totalSignatures) * totalReward + /// + private UInt256 CalculateProportionalReward( + long signatureCount, + long totalSignatures, + UInt256 totalReward) + { + if (signatureCount <= 0 || totalSignatures <= 0) + { + return UInt256.Zero; + } + + // Convert to UInt256 for precision + var signatures = (UInt256)signatureCount; + var total = (UInt256)totalSignatures; + + // Calculate: (signatures * totalReward) / total + // Order of operations matters to maintain precision + UInt256 numerator = signatures * totalReward; + UInt256 reward = numerator / total; + + return reward; + } + + private (BlockReward HolderReward, UInt256 FoundationWalletReward) DistributeRewards( + Address masternodeAddress, UInt256 reward, XdcBlockHeader header) + { + Address owner = masternodeVotingContract.GetCandidateOwner(header, masternodeAddress); + + // 90% of the reward goes to the masternode + UInt256 masterReward = reward * 90 / 100; + + // 10% of the reward goes to the foundation wallet + UInt256 foundationReward = reward / 10; + + return (new BlockReward(owner, masterReward), foundationReward); + } + } +}