diff --git a/src/Nethermind/Chains/xdc-testnet.json b/src/Nethermind/Chains/xdc-testnet.json index 4c1dbc14afa..d90b23ba330 100644 --- a/src/Nethermind/Chains/xdc-testnet.json +++ b/src/Nethermind/Chains/xdc-testnet.json @@ -45,9 +45,9 @@ "TimeoutSyncThreshold": 3, "TimeoutPeriod": 10, "MinePeriod": 2, - "MasternodeReward": 63420000000000001704, - "ProtectorReward": 50270000000000003128, - "ObserverReward": 25129999999999999006, + "MasternodeReward": 63.42, + "ProtectorReward": 50.27, + "ObserverReward": 25.13, "MinimumMinerBlockPerEpoch": 5, "LimitPenaltyEpoch": 5, "MinimumSigningTx": 30 diff --git a/src/Nethermind/Nethermind.Xdc.Test/XdcChainSpecRewardTests.cs b/src/Nethermind/Nethermind.Xdc.Test/XdcChainSpecRewardTests.cs new file mode 100644 index 00000000000..0bc658d11ad --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc.Test/XdcChainSpecRewardTests.cs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.IO; +using System.Text; +using Nethermind.Int256; +using Nethermind.Logging; +using Nethermind.Serialization.Json; +using Nethermind.Specs.ChainSpecStyle; +using Nethermind.Xdc.RPC; +using Nethermind.Xdc.Spec; +using NUnit.Framework; + +namespace Nethermind.Xdc.Test; + +/// +/// Covers reading XDPoS rewards, which the chainspec states in XDC, and the boundary that keeps +/// that unit convention inside engine.XDPoS.params. +/// +[TestFixture, Parallelizable(ParallelScope.All)] +public class XdcChainSpecRewardTests +{ + /// The only Apothem v2 config that sets rewards. + private const ulong ApothemRewardSwitchRound = 27_360_000; + + private const string RewardPlaceholder = "$REWARD$"; + private const string CoreParamsPlaceholder = "$CORE_PARAMS$"; + private const string EnginePeriodPlaceholder = "$PERIOD$"; + + private const string ChainSpecTemplate = $$""" + { + "name": "xdc-reward-test", + "engine": { + "XDPoS": { + "params": { + "period": {{EnginePeriodPlaceholder}}, + "epoch": 900, + "masternodeReward": {{RewardPlaceholder}}, + "v2Configs": [ + { + "SwitchRound": 0, + "MasternodeReward": {{RewardPlaceholder}} + } + ] + } + } + }, + "params": { "chainId": 50{{CoreParamsPlaceholder}} } + } + """; + + /// + /// The three Apothem amounts are the ones that pin the conversion: the reference scales the + /// float64 through a 64-bit significand and truncates, so 63.42 XDC is 63420000000000001704 wei + /// rather than a round 6.342e19. Getting this wrong changes what validators are paid. + /// + [TestCase("63.42", "63420000000000001704", TestName = "Masternode reward")] + [TestCase("50.27", "50270000000000003128", TestName = "Protector reward")] + [TestCase("25.13", "25129999999999999006", TestName = "Observer reward")] + [TestCase("6.342e1", "63420000000000001704", TestName = "Exponent notation is the same amount")] + [TestCase("1", "1000000000000000000", TestName = "Whole XDC")] + [TestCase("2.5", "2500000000000000000", TestName = "Exactly representable fraction")] + [TestCase("0.5", "500000000000000000", TestName = "Fraction below one")] + [TestCase("1e-18", "1", TestName = "One wei")] + [TestCase("1e-19", "0", TestName = "Below one wei truncates to zero")] + [TestCase("0", "0", TestName = "Zero")] + [TestCase("0.0", "0", TestName = "Zero with a fraction")] + public void Reward_stated_in_xdc_is_converted_to_wei(string literal, string expectedWei) + { + XdcChainSpecEngineParameters parameters = LoadEngineParameters(literal); + + UInt256 wei = UInt256.Parse(expectedWei); + Assert.Multiple(() => + { + Assert.That(parameters.MasternodeReward, Is.EqualTo(wei)); + Assert.That(parameters.V2Configs[0].MasternodeReward, Is.EqualTo(wei)); + }); + } + + [TestCase("-1", TestName = "Negative")] + [TestCase("-0.0", TestName = "Negative zero")] + [TestCase("1e60", TestName = "Beyond UInt256 once scaled")] + [TestCase("\"0x37f0e6c9e9dd0e0000\"", TestName = "Hex string, which would have to mean wei")] + [TestCase("\"63.42\"", TestName = "Quoted amount")] + public void Reward_that_is_not_an_xdc_amount_is_rejected(string literal) => + Assert.That(() => LoadEngineParameters(literal), Throws.TypeOf()); + + /// + /// The conversion is opt-in per property, so an XDPoS field that was not annotated keeps the + /// shared converter's behaviour. + /// + [Test] + public void Fractional_value_in_an_unannotated_engine_field_is_rejected() => + Assert.That(() => LoadEngineParameters("1", period: "2.0"), Throws.TypeOf()); + + /// + /// The converter is attached to properties owned by Nethermind.Xdc, so nothing outside the + /// XDPoS engine section gains either the XDC unit or tolerance for a fractional number. + /// + [TestCase(", \"eip150Transition\": 2.0", TestName = "Transition block")] + [TestCase(", \"terminalTotalDifficulty\": 1e18", TestName = "Terminal total difficulty")] + public void Fractional_value_outside_the_engine_section_is_rejected(string coreParams) => + Assert.That(() => LoadEngineParameters("1", coreParams: coreParams), Throws.TypeOf()); + + /// + /// Pins the shipped Apothem chainspec to the wei amounts it resolved to before the rewards were + /// restated in XDC. + /// + [Test] + public void Shipped_testnet_rewards_are_unchanged() + { + V2ConfigParams config = LoadShippedTestnetRewardConfig(); + + Assert.Multiple(() => + { + Assert.That(config.MasternodeReward, Is.EqualTo(UInt256.Parse("63420000000000001704"))); + Assert.That(config.ProtectorReward, Is.EqualTo(UInt256.Parse("50270000000000003128"))); + Assert.That(config.ObserverReward, Is.EqualTo(UInt256.Parse("25129999999999999006"))); + }); + } + + /// + /// doubles as the XDPoS_networkInformation response DTO, so + /// the reward properties are serialized as well as read. The response reports wei, unchanged by + /// the chainspec moving to XDC. + /// + [Test] + public void Network_information_response_reports_rewards_in_wei() + { + NetworkInformation response = new() + { + ConsensusConfigs = new XDPoSConfig { V2Configs = [LoadShippedTestnetRewardConfig()] } + }; + + string json = new EthereumJsonSerializer().Serialize(response); + + // The three rewards as the QUANTITY the endpoint has always emitted. + Assert.Multiple(() => + { + Assert.That(json, Does.Contain("0x3702119fc874606a8")); + Assert.That(json, Does.Contain("0x2b9a2eaa87ae30c38")); + Assert.That(json, Does.Contain("0x15cbfb1db0590fc1e")); + }); + } + + private static V2ConfigParams LoadShippedTestnetRewardConfig() + { + ChainSpec chainSpec = new ChainSpecFileLoader(new EthereumJsonSerializer(), LimboLogs.Instance) + .LoadEmbeddedOrFromFile("chainspec/xdc-testnet.json"); + + XdcChainSpecEngineParameters parameters = chainSpec.EngineChainSpecParametersProvider + .GetChainSpecParameters(); + + foreach (V2ConfigParams config in parameters.V2Configs) + { + if (config.SwitchRound == ApothemRewardSwitchRound) return config; + } + + throw new AssertionException($"No v2 config at switch round {ApothemRewardSwitchRound}"); + } + + private static XdcChainSpecEngineParameters LoadEngineParameters(string reward, string coreParams = "", string period = "2") + { + string json = ChainSpecTemplate + .Replace(RewardPlaceholder, reward) + .Replace(CoreParamsPlaceholder, coreParams) + .Replace(EnginePeriodPlaceholder, period); + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + ChainSpec chainSpec = new ChainSpecLoader(new EthereumJsonSerializer(), LimboLogs.Instance).Load(stream); + + return chainSpec.EngineChainSpecParametersProvider.GetChainSpecParameters(); + } +} diff --git a/src/Nethermind/Nethermind.Xdc/README.md b/src/Nethermind/Nethermind.Xdc/README.md index 72e16b44ecc..69dfc4dd7fd 100644 --- a/src/Nethermind/Nethermind.Xdc/README.md +++ b/src/Nethermind/Nethermind.Xdc/README.md @@ -289,7 +289,7 @@ transactions observed two epochs back (blocks at heights that are multiples of ` - **Pre-`TIPUpgradeReward`** — `Reward` XDC for the epoch, split proportionally to each masternode's signing count. -- **Post-`TIPUpgradeReward`** — fixed `MasternodeReward` / `ProtectorReward` / `ObserverReward` (Wei) per +- **Post-`TIPUpgradeReward`** — fixed `MasternodeReward` / `ProtectorReward` / `ObserverReward` (XDC) per qualifying signer, with minted and burned totals reported to the minted-record contract ([`IMintedRecordContract`](Contracts/IMintedRecordContract.cs)). @@ -557,7 +557,7 @@ either fails to load. | `TimeoutPeriod` | **seconds** | Round timeout before a timeout vote is broadcast | | `TimeoutSyncThreshold` | count | Broadcast `SyncInfo` after this many consecutive timeouts | | `MinePeriod` | **seconds** | Minimum spacing between a parent block and its child. `2` | -| `MasternodeReward` / `ProtectorReward` / `ObserverReward` | Wei | Fixed per-signer epoch rewards (post-`TIPUpgradeReward`) | +| `MasternodeReward` / `ProtectorReward` / `ObserverReward` | XDC | Fixed per-signer epoch rewards (post-`TIPUpgradeReward`). Stated in XDC, as in the reference client, and scaled to wei on load. `63.42` on Apothem | | `MinimumMinerBlockPerEpoch` | blocks | Below this, a masternode is penalised. Only honoured once `TIPUpgradePenalty` is active; before that a hard-coded `1` applies | | `LimitPenaltyEpoch` | epochs | Penalty duration used post-`TIPUpgradePenalty` | | `MinimumSigningTx` | count | Signing transactions needed to leave penalty | diff --git a/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecEngineParameters.cs b/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecEngineParameters.cs index c5e86ce654b..d0e67dafbe1 100644 --- a/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecEngineParameters.cs +++ b/src/Nethermind/Nethermind.Xdc/Spec/XdcChainSpecEngineParameters.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Text.Json.Serialization; using Nethermind.Core; using Nethermind.Int256; using Nethermind.Specs; @@ -68,8 +69,11 @@ public List V2Configs public ulong? TipUpgradePenalty { get; set; } public ulong? TipUpgradeReward { get; set; } + [JsonConverter(typeof(XdcToWeiConverter))] public UInt256 MasternodeReward { get; set; } + [JsonConverter(typeof(XdcToWeiConverter))] public UInt256 ProtectorReward { get; set; } + [JsonConverter(typeof(XdcToWeiConverter))] public UInt256 ObserverReward { get; set; } public ulong MergeSignRange { get; set; } public Address[] BlackListedAddresses { get; set; } @@ -122,8 +126,11 @@ public sealed class V2ConfigParams public int TimeoutSyncThreshold { get; init; } public int TimeoutPeriod { get; init; } public ulong MinePeriod { get; init; } + [JsonConverter(typeof(XdcToWeiConverter))] public UInt256 MasternodeReward { get; init; } + [JsonConverter(typeof(XdcToWeiConverter))] public UInt256 ProtectorReward { get; init; } + [JsonConverter(typeof(XdcToWeiConverter))] public UInt256 ObserverReward { get; init; } public ulong MinimumMinerBlockPerEpoch { get; init; } public ulong LimitPenaltyEpoch { get; init; } diff --git a/src/Nethermind/Nethermind.Xdc/Spec/XdcToWeiConverter.cs b/src/Nethermind/Nethermind.Xdc/Spec/XdcToWeiConverter.cs new file mode 100644 index 00000000000..8cf0dedd236 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc/Spec/XdcToWeiConverter.cs @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Nethermind.Int256; +using Nethermind.Serialization.Json; + +namespace Nethermind.Xdc.Spec; + +/// +/// Reads a reward stated in XDC — 63.42, 1, 2.5e1 — and yields the amount in wei. +/// +/// +/// The reference client decodes these fields into a float64 and scales them by 10^18 through a +/// big.Float whose precision defaults to 64 bits, then truncates toward zero. Both steps are +/// load-bearing: 63.42 has to produce 63420000000000001704 — the value Apothem's genesis was +/// generated with — and neither nor arithmetic lands +/// there. So the exact product is formed in and rounded explicitly. +/// +/// The 64 comes from Go raising a zero-precision big.Float to that width, which only survives +/// into the product while the receiver's precision is unset — the detail that would silently change +/// the result if the reference restructured that expression. Reward, the pre-TIPUpgradeReward +/// equivalent, is likewise a whole-XDC value scaled by Unit.Ether in . +/// +/// +/// Only a JSON number is accepted. A hex QUANTITY would have to mean wei, and carrying two units on +/// one field is how a reward ends up wrong by a factor of 10^18. +/// +/// +/// Applying this per property keeps the XDC unit convention inside engine.XDPoS.params. The +/// converters registered on are untouched, so the rest of the +/// chainspec — and JSON-RPC, where EIP-1474 requires a QUANTITY — still rejects a fractional number. +/// +/// +public sealed class XdcToWeiConverter : JsonConverter +{ + private const int WeiPerXdcExponent = 18; + + /// Significand width the reference conversion rounds to. + private const int ReferencePrecisionBits = 64; + + private const int UInt256ByteCount = 32; + + private static readonly UInt256Converter WeiConverter = new(); + + public override UInt256 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.Number) ThrowNotANumber(reader.TokenType); + + if (!reader.TryGetDouble(out double amount) || !double.IsFinite(amount) || double.IsNegative(amount)) + { + ThrowNotAnAmount(in reader); + } + + return ToWei(amount, in reader); + } + + /// + /// Writes wei rather than XDC. doubles as the + /// XDPoS_networkInformation response DTO, whose wire format must not change, and reporting + /// wei over JSON-RPC while configuring XDC in the chainspec is the split used everywhere else. + /// The consequence is that a does not survive a JSON round trip; + /// nothing performs one. + /// + public override void Write(Utf8JsonWriter writer, UInt256 value, JsonSerializerOptions options) => + WeiConverter.Write(writer, value, options); + + private static UInt256 ToWei(double amount, in Utf8JsonReader reader) + { + if (amount == 0) return default; + + (BigInteger significand, int exponent) = Decompose(amount); + + // The amount is `significand * 2^exponent`, so its wei value is `numerator / 2^shift` exactly. + BigInteger numerator = significand * BigInteger.Pow(10, WeiPerXdcExponent); + int shift = -exponent; + if (shift < 0) + { + numerator <<= -shift; + shift = 0; + } + + // A value in [2^(e-1), 2^e) keeps bits down to 2^(e-64); `numerator`'s bit length minus the + // shift is that e, because the divisor is a power of two. + int ulpExponent = (int)numerator.GetBitLength() - shift - ReferencePrecisionBits; + + BigInteger rounded = ShiftRoundHalfEven(numerator, shift + ulpExponent); + BigInteger wei = ulpExponent >= 0 ? rounded << ulpExponent : rounded >> -ulpExponent; + + int byteCount = wei.GetByteCount(isUnsigned: true); + if (byteCount > UInt256ByteCount) ThrowTooLarge(in reader); + + Span bytes = stackalloc byte[UInt256ByteCount]; + wei.TryWriteBytes(bytes[(UInt256ByteCount - byteCount)..], out _, isUnsigned: true, isBigEndian: true); + + ReadOnlySpan bigEndian = bytes; + return new UInt256(in bigEndian, isBigEndian: true); + } + + /// Splits a positive finite into significand * 2^exponent. + private static (BigInteger Significand, int Exponent) Decompose(double value) + { + long bits = BitConverter.DoubleToInt64Bits(value); + int biasedExponent = (int)((bits >> 52) & 0x7FF); + long significand = bits & 0xF_FFFF_FFFF_FFFF; + + return biasedExponent == 0 + ? (significand, -1074) + : (significand | (1L << 52), biasedExponent - 1075); + } + + /// Divides by 2^shift, rounding half to even; a negative shift multiplies instead. + private static BigInteger ShiftRoundHalfEven(BigInteger value, int shift) + { + if (shift <= 0) return value << -shift; + + BigInteger quotient = value >> shift; + BigInteger remainder = value - (quotient << shift); + int comparedToHalf = remainder.CompareTo(BigInteger.One << (shift - 1)); + + return comparedToHalf > 0 || (comparedToHalf == 0 && !quotient.IsEven) + ? quotient + BigInteger.One + : quotient; + } + + private static string Literal(in Utf8JsonReader reader) => + Encoding.UTF8.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan); + + [DoesNotReturn, StackTraceHidden] + private static void ThrowNotANumber(JsonTokenType tokenType) => + throw new JsonException($"An XDC reward must be a JSON number, found {tokenType}"); + + [DoesNotReturn, StackTraceHidden] + private static void ThrowNotAnAmount(in Utf8JsonReader reader) => + throw new JsonException($"'{Literal(in reader)}' is not an XDC amount"); + + [DoesNotReturn, StackTraceHidden] + private static void ThrowTooLarge(in Utf8JsonReader reader) => + throw new JsonException($"'{Literal(in reader)}' XDC exceeds the largest amount representable in wei"); +}