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");
+}