Skip to content

feat(xdc): read XDPoS rewards as XDC amounts - #12852

Open
ak88 wants to merge 4 commits into
masterfrom
claude/chainspec-float-compatibility-b4af83
Open

feat(xdc): read XDPoS rewards as XDC amounts#12852
ak88 wants to merge 4 commits into
masterfrom
claude/chainspec-float-compatibility-b4af83

Conversation

@ak88

@ak88 ak88 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Changes

  • Add XdcToWeiConverter, which reads an XDPoS reward stated in XDC (63.42, 1, 2.5e1) and yields the amount in wei.
  • Apply it to MasternodeReward / ProtectorReward / ObserverReward on XdcChainSpecEngineParameters and V2ConfigParams.
  • Restate the Apothem rewards in xdc-testnet.json as the reference amounts (63.42 / 50.27 / 25.13) instead of pre-converted wei.

Why

The reference client states these rewards in XDC and scales them to wei on load. That conversion is lossy in a specific way, so Apothem's genesis carries 63420000000000001704 rather than a round 6.342e19. Nethermind's chainspec copied those pre-converted integers, which left the client unable to read a reference genesis and left the constants unexplained.

The converter reproduces the reference conversion exactly: decode the literal to a float64, form the exact product with 10^18, round that to a 64-bit significand (half-to-even), then truncate toward zero. Both steps are load-bearing — plain float64(63.42) * 1e18 gives a round 6.342e19, which is wrong by 1704 wei per block — and neither double nor decimal arithmetic lands on the reference values, so the product is formed in BigInteger and rounded explicitly.

Scope

Applying the converter per property confines the XDC unit convention to engine.XDPoS.params. ChainSpecJson keeps the engine section as raw JsonElement, JsonElement.ToString() preserves the literal text, and a property-level [JsonConverter] outranks the options-level ones — so the converters registered on EthereumJsonSerializer are untouched and the rest of the chainspec, plus JSON-RPC, keep rejecting fractional numbers as EIP-1474 requires. No code outside Nethermind.Xdc changed.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

XdcChainSpecRewardTests — 19 cases, all driven through the real ChainSpecLoader rather than the converter in isolation, so they also cover the ChainSpecParametersProvider hop.

  • The three Apothem amounts, pinned to the exact wei the reference produces.
  • Exponent notation, whole XDC, exactly representable fractions, one wei, sub-wei truncation, zero.
  • Rejection of negatives, amounts beyond UInt256 once scaled, hex strings and quoted amounts.
  • Shipped_testnet_rewards_are_unchanged loads the embedded xdc-testnet.json and asserts the three original wei integers, so any drift in the rounding fails the build.
  • Three isolation tests: a fractional value is still rejected in an unannotated XDPoS field (period), in params.eip150Transition, and in params.terminalTotalDifficulty.

590/590 Nethermind.Xdc.Test pass; dotnet format whitespace --verify-no-changes is clean.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

MasternodeReward, ProtectorReward and ObserverReward in engine.XDPoS.params are now read as XDC amounts rather than wei, matching the reference client. This affects whole numbers too: "MasternodeReward": 5 now means 5 XDC. A hex string is no longer accepted on these fields, since it could only mean wei and carrying two units on one field is how a reward ends up wrong by a factor of 10^18.

The shipped chainspecs are embedded in the build and replaced on upgrade, so no migration is needed for xdc or xdc-testnet. An operator-supplied chainspec passed via --Init.ChainSpecPath that states these rewards in wei would need to be restated in XDC.

Remarks

The conversion rule was derived from the three amounts Apothem's genesis already carries and verified to reproduce all of them exactly. Worth a second pair of eyes on that derivation, since it is consensus-relevant: it is the only part of this change that cannot be checked by reading the reference format alone.

Write throws NotSupportedException (same as BlockRewardConverter) — wei to XDC is not invertible, and emitting the wei value would read back scaled by 10^18. Nothing serializes engine parameters.

🤖 Generated with Claude Code

The reference client states masternode, protector and observer rewards in
XDC and scales them to wei on load, so Apothem's genesis carries values
such as 63420000000000001704 rather than a round 6.342e19. Nethermind's
chainspec copied those pre-converted integers, which left the client
unable to read a reference genesis and left the odd constants
unexplained.

Add a converter that reproduces the reference conversion exactly: decode
the literal to a float64, form the exact product with 10^18, round it to
a 64-bit significand and truncate toward zero. Neither double nor decimal
arithmetic reaches those values, so the product is built in BigInteger
and rounded explicitly.

Apply it per property, which confines the unit convention to
engine.XDPoS.params: the converters registered on EthereumJsonSerializer
are untouched, so the rest of the chainspec and JSON-RPC keep rejecting
fractional numbers as EIP-1474 requires. Restate the Apothem rewards as
the reference amounts; a test loads the shipped file and pins the wei
they resolve to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 9m 28s —— View job


Review — feat(xdc): read XDPoS rewards as XDC amounts

  • Gather context (diff, changed files, surrounding XDC module)
  • Verify the XDC→wei conversion derivation against the three Apothem values
  • Review converter implementation for correctness and edge cases
  • Review chainspec change and test coverage
  • Post findings

The arithmetic is right. I re-derived all three Apothem amounts independently and the rule you describe — exact product with 10¹⁸, round to a 64-bit significand half-to-even, truncate — reproduces every one of them, while the two nearest alternative rules reproduce none. Details and working are in the inline comment on the <remarks> block. That was the part you flagged for a second pair of eyes, and it holds up.

The problems are around the converter, not inside it.

Critical

XDPoS_networkInformation will throw. V2ConfigParams is not only a chainspec type — it is reused verbatim as a JSON-RPC response DTO, reached through XDPoSConfig.V2ConfigsNetworkInformation.ConsensusConfigsXdcRpcModule.cs:626. A property-level [JsonConverter] applies on write, so serializing that response now hits Write and its NotSupportedException — mid-stream, after the enclosing object has begun. The PR's "nothing serializes engine parameters" is the assumption that doesn't hold. There is no test touching this endpoint, which is why 590/590 passed. Details →

High

An un-migrated wei value loads silently rather than failing. The only upper bound is UInt256 overflow, and 63420000000000001704 read as XDC is ~6.342e37 wei — far under 2^256. So the exact literal this PR deletes from xdc-testnet.json, if it survives in an operator's chainspec, starts the node and inflates every reward by 10¹⁸ with no log line. That's a live path: XDC subnets are operator-deployed with hand-written chainspecs, and the field name and JSON shape are unchanged, so nothing signals that the unit moved. A plausibility bound catches 100% of wei-denominated values for one comparison. Details →

Medium

Nethermind.Xdc/README.md still documents these fields as Wei — the v2Configs table at line 560 (`MasternodeReward` / `ProtectorReward` / `ObserverReward` | Wei | …) and the prose at line 292. The PR ticks "requires documentation update: No", but this is the in-repo reference for exactly the unit being changed, and it's now wrong. Note the same table correctly documents CertificateThreshold as a fraction, so it's already the place a reader checks for this. Worth also noting there that Reward (pre-TIPUpgradeReward) is a whole-XDC ulong scaled by Unit.Ether in XdcRewardCalculator.cs:83 — which is good supporting precedent for this change, and worth citing in the PR description. Fix this →

The rejection tests miss the migration case. 1e60 exercises the UInt256 ceiling; the input operators will actually hit is a wei-denominated integer, and that case would currently fail. Also flagged: no test covers the write path, and Shipped_testnet_rewards_are_unchanged selects V2Configs[^1] positionally. Details →

Low

-0.0 is accepted (amount < 0 is false for negative zero); literal is materialized on every read though it is only used to build exception messages. Both in one comment →

What I checked and found clean

  • Mainnet is unaffected. xdc.json has no MasternodeReward / ProtectorReward / ObserverReward entries at all, so only xdc-testnet.json changes meaning. (Separately: that means post-TIPUpgradeReward mainnet rewards resolve to zero — pre-existing, not this PR, but you may want to confirm it's intentional.)
  • The scoping argument holds. ChainSpecParametersProvider only ever calls Deserialize on json.ToString() (line 59), the engine section stays a raw JsonElement, and the property-level attribute outranks the options-level converters. Nothing outside engine.XDPoS.params gains the XDC unit or tolerance for a fractional number, and the three isolation tests are the right ones to have written.
  • Numeric edge cases in ToWei. The e = bitLength(numerator) − shift identity is correct because the divisor is a power of two; subnormals (shift up to 1074) and double.MaxValue (numerator shifted left ~971 bits) both stay in range with no int overflow; rounding up across a binade boundary is value-preserving; the byteCount > 32 guard is placed after rounding, which is the correct order.
  • Converter is stateless, so no thread-safety concern; load-time only, so no hot-path cost.

Verdict

Not mergeable as-is — the XDPoS_networkInformation regression is a hard runtime failure of a shipped endpoint, and the silent-inflation path deserves a guard given the change is consensus-relevant and explicitly labelled BREAKING. The core conversion, its scoping, and the reasoning behind both are sound; I'd take this once those two are addressed and the README is brought along.

I reviewed only — no changes pushed.
· branch claude/chainspec-float-compatibility-b4af83

Comment on lines +116 to 121
[JsonConverter(typeof(XdcToWeiConverter))]
public UInt256 MasternodeReward { get; init; }
[JsonConverter(typeof(XdcToWeiConverter))]
public UInt256 ProtectorReward { get; init; }
[JsonConverter(typeof(XdcToWeiConverter))]
public UInt256 ObserverReward { get; init; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical — this breaks the XDPoS_networkInformation JSON-RPC method.

V2ConfigParams is not only a chainspec type; it is reused verbatim as an RPC response DTO:

A property-level [JsonConverter] applies on write too, so serializing the XDPoS_networkInformation response now calls XdcToWeiConverter.Write and throws NotSupportedException. Because JSON-RPC serializes straight into the response stream, this doesn't just fail the call cleanly — the exception lands mid-object, after NetworkInformation/XDPoSConfig have already been partially written.

The PR description states "Nothing serializes engine parameters"; that is the assumption that doesn't hold here.

Before this PR the endpoint emitted these three fields through the default UInt256 converter. Options, roughly in the order the repo guidelines prefer:

  1. Make Write reproduce the previous output (delegate to the default UInt256 handling) rather than throwing. The read/write asymmetry is real but confined, and it keeps the wire format unchanged.
  2. Give the RPC path its own DTO so the chainspec unit convention never reaches a response type.

Either way this needs a regression test — Nethermind.Xdc.Test currently has no coverage of XDPoS_networkInformation at all, which is why 590/590 passed.

Fix this →

Comment on lines +88 to +90

int byteCount = wei.GetByteCount(isUnsigned: true);
if (byteCount > UInt256ByteCount) ThrowNotAnAmount(literal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — the only upper bound is UInt256 overflow, so an un-migrated wei value loads silently.

This is the migration hazard the PR flags in the release notes, but nothing here catches it. Take the exact value this PR removes from xdc-testnet.json:

"MasternodeReward": 63420000000000001704

Read as XDC that is 6.342e19 × 10^18 ≈ 6.342e37 wei — comfortably under 2^256 ≈ 1.16e77, so byteCount > UInt256ByteCount never trips. The node starts and pays every masternode a reward inflated by 10^18, with no log line and no load failure. The rejection test only covers 1e60; 1e58 would be accepted.

That matters more than a normal config typo because XDC subnets are operator-deployed with hand-written chainspecs (XdcSubnetChainSpecEngineParameters), and the field name and JSON shape are unchanged — an operator gets no signal that the unit moved under them.

A plausibility bound would catch 100% of wei-denominated values, since they are 18 orders of magnitude above any real reward. Rejecting anything above, say, 10^12 XDC (still far above total supply, and ~25 orders of magnitude below the UInt256 ceiling) turns a silent consensus divergence into a startup error, at the cost of one comparison. Worth a named constant with the rationale in its doc comment.

Fix this →

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no subnets have even been deployed, this is not relevant

Comment on lines +21 to +37
/// <remarks>
/// The reference client decodes these fields into a float64 and scales them by 10^18 through a
/// <c>big.Float</c> carrying the 64-bit significand its wei multiplier is built with, then truncates
/// toward zero. Both steps are load-bearing: <c>63.42</c> has to produce
/// 63420000000000001704 — the value Apothem's genesis was generated with — and neither
/// <see cref="double"/> nor <see cref="decimal"/> arithmetic lands there. So the exact product is
/// formed in <see cref="BigInteger"/> and rounded explicitly.
/// <para>
/// 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.
/// </para>
/// <para>
/// Applying this per property keeps the XDC unit convention inside <c>engine.XDPoS.params</c>. The
/// converters registered on <see cref="EthereumJsonSerializer"/> are untouched, so the rest of the
/// chainspec — and JSON-RPC, where EIP-1474 requires a QUANTITY — still rejects a fractional number.
/// </para>
/// </remarks>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The derivation checks out — I reproduced all three Apothem amounts by hand. Recording the working here since the PR asks for a second pair of eyes on it, and since it's the part no reviewer can check by reading the reference format alone.

For each literal: take the exact double, form the exact product with 10^18, round to a 64-bit significand (half-to-even), truncate.

Literal double significand (exact real value) Exact product ulp @ 64 bits Rounded → truncated
63.42 8925571511494902 / 2⁴⁷ (exact: …901.76) 63420000000000001705.30 4 (2⁶⁵ ≤ x < 2⁶⁶) …1704
50.27 7074873539622339 / 2⁴⁷ (exact: …338.56) 50270000000000003126.39 4 …3128
25.13 7073466164738785 / 2⁴⁸ (exact: …785.28) 25129999999999999005.24 2 (2⁶⁴ ≤ x < 2⁶⁵) …9006

All three land exactly on the values Apothem's genesis carries. The two nearby hypotheses both fail, so the rule is pinned rather than merely consistent:

  • Truncate the exact product, no intermediate rounding…1705 / …3126 / …9005. Wrong on all three.
  • Round to 53 bits (what you'd get if the Go receiver already had prec == 53, i.e. bigval.Mul(bigval, coin) rather than new(big.Float).Mul(...)) → ulp is 8192 at 6.342e19, and 63420000000000000000 happens to be an exact multiple of 8192, so 63.42 collapses to the round value. Wrong.

Note the sign structure is what makes this convincing: two amounts round up past the round value and one rounds down, and the offsets (+1704, +3128, −994) aren't a common relative factor — they track the individual double representation error of each literal. That's hard to hit by accident.

Two small things on the comment itself:

  • ReferencePrecisionBits = 64 is the load-bearing constant and its provenance is "the three Apothem values are consistent with it". Worth naming the reference source (repo/file/function) in the <remarks> so a future reader can confirm rather than re-derive. In Go terms the 64 comes from SetInt raising a zero-precision big.Float to 64, and it only survives into the product if the receiver's precision is unset — which is exactly the detail that would silently change the answer if the reference ever restructured that expression.
  • Line 24 says the multiplier "carr[ies] the 64-bit significand its wei multiplier is built with", but 10¹⁸ needs only 60 bits — the 64 is a floor, not a fit. Slightly clearer as "whose precision defaults to 64 bits".

Comment on lines +51 to +57
ReadOnlySpan<byte> literal = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan;
if (!reader.TryGetDouble(out double amount) || !double.IsFinite(amount) || amount < 0)
{
ThrowNotAnAmount(literal);
}

return ToWei(amount, literal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — -0.0 slips through. amount < 0 is false for negative zero, so "-0.0" is accepted and ToWei returns default. Harmless in effect, but it's the one negative literal the rejection path lets past, and double.IsNegative(amount) closes it without changing any other case.

Low — literal is computed on every read, including the success path. reader.ValueSequence.ToArray() allocates, and the value is only ever used to build an exception message. Chainspec load isn't a hot path so this is cosmetic, but deferring the materialization into the two throw helpers (they can take the reader's raw bytes, or the Utf8JsonReader by in) keeps the happy path allocation-free and matches the repo's low-allocation preference.

Comment on lines +76 to +81
[TestCase("-1", TestName = "Negative")]
[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<InvalidDataException>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — the rejection cases don't cover the one input the migration actually produces.

1e60 only proves the UInt256 ceiling works. The realistic bad input is an operator chainspec that still states the reward in wei — literally the value this PR deletes from xdc-testnet.json:

[TestCase("63420000000000001704", TestName = "Reward still stated in wei")]

Today that case would fail, because 6.342e37 wei fits in a UInt256 and loads silently (see the comment on XdcToWeiConverter.cs). It's worth adding alongside whatever bound you settle on, so the test documents the migration boundary rather than just the arithmetic one.

Two more gaps worth closing while you're here:

  • The write path is untested, which is why the XDPoS_networkInformation breakage got through. A test that serializes a V2ConfigParams (or the whole NetworkInformation response) would have caught it.
  • Shipped_testnet_rewards_are_unchanged reads V2Configs[^1] with the comment "the newest config … sorts last by switch round". That's true, but the assertion is only meaningful because that entry happens to be the one carrying rewards. If a later config is appended without reward fields, this silently starts asserting three zeros against three non-zero expectations — it'd fail, so it's safe, just confusingly. Selecting by SwitchRound would say what the test means.

V2ConfigParams is not only a chainspec type: it is the XDPoS_networkInformation
response DTO, reached through XDPoSConfig.V2Configs. A property-level
JsonConverter applies on write, so the NotSupportedException thrown by
XdcToWeiConverter.Write landed mid-object once the response had begun
serializing. Write now emits wei, exactly what the endpoint produced before,
and a test serializes the response to pin it.

Reading XDC while writing wei is asymmetric, but deliberate: operators
configure in XDC as the reference does, and the API reports wei as every other
Ethereum endpoint does. Nothing round-trips the type.

Bound the amount at 10^12 XDC. UInt256 overflow was the only ceiling, and a
reward left in the previous wei spelling is ~10^18 times any real value yet
still far inside UInt256 -- so it started the node and inflated every payout
silently. Above the bound the load now fails with a message naming the unit.

Also correct the two README entries still documenting these fields as Wei,
reject negative zero, defer the literal to the throw paths, and select the
Apothem config by switch round rather than position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ak88

ak88 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@claude

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Re-review — follow-up commit 84b8591

  • Read the follow-up commit and diff it against my earlier findings
  • Verify the Write fix reproduces the pre-PR wire format exactly
  • Verify the plausibility bound and that removing the UInt256 overflow check is safe
  • Check README, test additions, and remaining nits
  • Post findings

All three of my blocking/major findings are addressed, and the fixes are the right ones. Nothing new blocking.

On your reply about subnets — fair, the deployment framing was wrong and I'll drop it. The bound still earns its place for an operator-supplied --Init.ChainSpecPath, which is the path the release notes call out, and that's what it now covers.

Verified fixed

XDPoS_networkInformation (was critical). Write now delegates to UInt256Converter.Write, and that reproduces the pre-PR bytes exactly: strictQuantity only affects Read (UInt256Converter.cs:29), and Write is stateless — it dispatches on the ThreadStatic ForcedNumberConversion.Value, so the property-level converter and the options-level one produce identical output including under a forced Decimal/Raw scope. The wire format is unchanged, not merely similar.

The test's expected QUANTITY is right — I checked 63420000000000001704 = 0x3702119fc874606a8 by hand (3·2⁶⁴ + 0x702119fc874606a8).

Worth noting the asymmetry is now fail-safe in both directions: a round trip doesn't silently inflate, it throws — Write emits a hex string, Read requires JsonTokenType.Number, so a written value can never be read back as XDC. That's a stronger property than "nothing performs one" and worth a clause in the <remarks> since it's what makes the asymmetry defensible.

The plausibility bound (was high). 1e12 XDC is a good choice — two orders above total supply, six below the smallest wei-denominated value, so it rejects 100% of un-migrated values with no chance of a false positive. The error message names the unit and the 10¹⁸ factor, which is what an operator needs.

Removing if (byteCount > UInt256ByteCount) is safe: 1e12 is exactly representable, the product is ≤ 10³⁰ (100 bits), and rounding to a 64-bit significand adds at most one ulp, so wei can't reach 32 bytes. The guard is now the bound, and the comment at line 111 says so.

README (was medium). Both entries corrected, and the table row picked up the Apothem reference value.

Nits from the low/medium comments. double.IsNegative closes -0.0; Literal is deferred to the throw helpers; the shipped-config lookup selects by SwitchRound. All as suggested.

Small things left

[TestCase("1e60", TestName = "Beyond UInt256 once scaled")] (XdcChainSpecRewardTests.cs:90) — the name is now wrong. 1e60 trips MaxPlausibleAmountInXdc, and the UInt256 ceiling it claims to exercise no longer exists as a check. Renaming to something like "Beyond the plausibility bound" keeps the case honest; a value just over the bound (1.1e12) would pin the boundary itself, which nothing currently does.

ThrowImplausible's message on a huge input — for 1e60 it advises dividing by 10¹⁸, which won't help. Harmless, but if you want it tight, the wei hint only applies below ~1e31.

Network_information_response_reports_rewards_in_wei asserts one of the three rewards and reads the ambient ForcedNumberConversion (default Hex = 0, so it holds). Asserting all three would cost one line and cover all three attributes; nothing else in the fixture mutates that ThreadStatic, so the ambient dependency is fine as-is.

Note on verification

I confirmed the arithmetic and the wire-format equivalence by reading UInt256Converter, ForcedNumberConversion, and NumberConversion, plus the hex conversion by hand. I could not run dotnet build or dotnet test in this job — both were denied by the tool allowlist — so the 590/590 and the new test's pass are your CI's word, not mine.

Verdict

The two blockers are properly resolved and the guard is well-reasoned. Ship it once the test name is corrected; the rest is optional polish.

View job run · branch claude/chainspec-float-compatibility-b4af83

No subnets have been deployed, and the shipped chainspecs are embedded in
the build and replaced on upgrade, so there is no wei-denominated config
for the bound to catch. It was guarding a migration that cannot happen.

Restore the UInt256 overflow check the bound had made unreachable, which
also makes the "beyond UInt256 once scaled" case exercise what its name
claims again. Assert all three rewards in the networkInformation test
rather than the masternode one alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ak88

ak88 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@claude

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 3m 1s —— View job


Re-review — 3a2dde6 (revert of the plausibility bound)

  • Read the revert commit and the current state of the converter
  • Verify the restored UInt256 overflow check is correct and reachable
  • Check the test changes and remaining nits from the last round
  • Post findings

The revert is clean and does what its message says. Nothing blocking; I have no further objections.

Verified

The restored overflow check is correct and reachable. 1e60 XDC scales to 1e78 wei, past 2^256 ≈ 1.1579e77, so wei.GetByteCount(isUnsigned: true) returns 33 and ThrowTooLarge fires — the case name matches what it exercises again. The guard sits before the stackalloc write (XdcToWeiConverter.cs:97-101), which is the ordering that matters: bytes[(32 - byteCount)..] would throw ArgumentOutOfRangeException on an oversized value otherwise.

Re-threading in Utf8JsonReader through ToWei for the throw path is fine — Literal is still only materialized inside the [DoesNotReturn] helpers, so the happy path stays allocation-free.

The three QUANTITYs in the widened networkInformation test are right. I converted all three by hand:

Wei Hex
63420000000000001704 0x3702119fc874606a8
50270000000000003128 0x2b9a2eaa87ae30c38
25129999999999999006 0x15cbfb1db0590fc1e

Each is a 17-digit value = d·2⁶⁴ + …; all three reconcile exactly. Asserting all three now covers all three attributes, as suggested.

XdcSubnetChainSpecEngineParameters only overrides SealEngineType and inherits the reward properties, so the annotations apply on the subnet path too — no shadowing to worry about.

Nothing else in the tree reads these fields through a serializer: XdcReleaseSpec / XdcRewardCalculator / XdcChainSpecBasedSpecProvider all take the already-converted UInt256, and the // in Wei comments on XdcReleaseSpec.cs:29-31 remain accurate — correctly left alone.

On dropping the bound

Your call, and the reasoning in the commit message is sound for the shipped chainspecs. One loose end it leaves: the PR's release-note paragraph still says an operator chainspec passed via --Init.ChainSpecPath "would need to be restated in XDC" without saying what happens if it isn't. With the bound gone, a wei-denominated value loads and inflates payouts by 10¹⁸ with no error — worth one clause in the release note so the operator-facing text matches the behaviour. Purely a wording change; no code implied.

Optional nits

  • ThrowTooLarge's message — "exceeds the largest amount representable in wei" is a shade off; the ceiling is UInt256, not wei as a unit. "…representable as a UInt256 of wei" would be exact.
  • No case near the real boundary. 1e60 is a full order of magnitude past the ceiling (~1.1579e59 XDC). A pair straddling it would pin the guard rather than just exercise it — genuinely optional, since the ceiling isn't a value anyone configures.

Verification caveat

I read the code and did the arithmetic by hand. dotnet build was denied by the tool allowlist in this job (as in the previous round), so I could not compile or run Nethermind.Xdc.Test — the test results are your CI's word, not mine.

Verdict

Ship it. The revert restores a correct guard, the test changes are accurate, and the earlier blockers stay fixed.
· branch claude/chainspec-float-compatibility-b4af83

@ak88
ak88 marked this pull request as ready for review August 17, 2026 21:02
@ak88
ak88 requested a review from cicr99 as a code owner August 17, 2026 21:02
@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 5m 3s —— View job


Review — feat(xdc): read XDPoS rewards as XDC amounts (ready-for-review pass)

  • Gather context (full diff, current file state, prior-round fixes)
  • Re-verify the conversion arithmetic and the Write path
  • Check for any other consumer of these fields (chainspecs, tests, RPC, subnet)
  • Review test coverage and docs
  • Post findings

Fresh pass over the final state of the branch (f6ae705, containing 173b2a484b85913a2dde6). No Critical, High, or Medium findings. Both earlier blockers are fixed, and I re-verified each one against the code rather than taking the earlier round's word for it.

Re-verified fixed

Write no longer breaks XDPoS_networkInformation. XdcToWeiConverter.Write delegates to a static UInt256Converter (line 51, 72-73). I read UInt256Converter end to end: _strictQuantity is consulted only in Read (line 29), and Write dispatches purely on the ThreadStatic ForcedNumberConversion.Value (line 119). So the parameterless-ctor instance produces byte-identical output to the options-level converter, under every forced-conversion scope. Wire format unchanged, and the static instance is immutable so sharing it is thread-safe.

The restored UInt256 guard is correct and correctly ordered. It sits before the stackalloc write, so bytes[(32 - byteCount)..] can never be handed a negative range.

README both places, -0.0, deferred Literal, SwitchRound lookup — all as suggested.

What I checked this round and found clean

  • Mainnet is unaffected for a stronger reason than previously stated. xdc.json has no TIPUpgradeReward key at all, and IsTipUpgradeRewardEnabled = (TipUpgradeReward ?? ulong.MaxValue) <= releaseStartBlock (XdcChainSpecBasedSpecProvider.cs:85) — so the fixed-reward branch in XdcRewardCalculator.cs:81 is unreachable on mainnet and these three fields are never read there. That retracts my earlier aside about mainnet rewards "resolving to zero" — the branch isn't taken, so there's nothing to confirm.
  • Shipped_testnet_rewards_are_unchanged is targeting the right entry. 27360000 is the only one of the four Apothem v2 configs carrying reward keys; the other three had no reward fields before this PR either, so they still resolve to zero. That test is the actual consensus safety net here — it pins Apothem's payouts to the pre-PR wei regardless of whether the derived rounding rule is a faithful model of the reference, which is the right thing to have anchored.
  • No other JSON entry point reaches these properties. I enumerated every V2ConfigParams reference in Nethermind.Xdc and Nethermind.Xdc.Test: the only deserialization is the chainspec, and the only serialization is the XDPoS_networkInformation response. Everything else (XdcReleaseSpec, XdcRewardCalculator, XdcChainSpecBasedSpecProvider, all the test fixtures) uses object initializers on an already-converted UInt256. The // in Wei comments on XdcReleaseSpec.cs:29-31 remain accurate and were correctly left alone.
  • The scoping claim holds, and the literal survives. ChainSpecParametersProvider deserializes from json.ToString() (line 59); JsonElement.ToString() on an object returns the raw token text, so 63.42 reaches the converter as 63.42 and not as a re-rendered double.
  • The isolation test is genuine, not vacuous. Period is a real ulong property (XdcChainSpecEngineParameters.cs:22) and EthereumJsonSerializer does not set UnmappedMemberHandling, so "period": 2.0 is rejected by the shared converter rather than silently skipped — the test would not pass for the wrong reason. JsonExceptionInvalidDataException wrapping is at ChainSpecLoader.cs:39, matching what the tests assert.
  • Input validation is complete for a chainspec-facing parser. Non-Number token, unparseable number, ±Infinity/NaN, and negatives (including -0.0) are all rejected before any arithmetic. An out-of-double-range literal like 1e400 is caught either way — TryGetDouble returning false, or double.IsFinite on an Infinity result.
  • Numeric edges. Decompose handles subnormals (shift to 1074) and double.MaxValue (numerator shifted left ~971 bits) with the widest numerator around 1084 bits, so ulpExponent has no int overflow risk. The e = bitLength(numerator) − shift identity is valid because the divisor is a power of two; ShiftRoundHalfEven degenerates to an exact shift when numerator is under 64 bits; and a half-even round up across a binade boundary is value-preserving.
  • Allocation. The in Utf8JsonReader refactor does what it set out to do: the reader is only touched inside the two [DoesNotReturn] helpers, so the success path allocates nothing. Load-time only, no hot-path cost.
  • Style. No var, no unused usings (System.Buffers for ReadOnlySequence.ToArray, System.Diagnostics for StackTraceHidden), composition over inheriting UInt256Converter, no code outside Nethermind.Xdc touched.

Low

ThrowTooLarge names the wrong ceiling, and no case pins the boundary. Details →

The release note doesn't say what happens if an operator chainspec isn't migrated. With the plausibility bound reverted — your call, and the reasoning for the shipped chainspecs is sound — a wei-denominated --Init.ChainSpecPath value loads and inflates payouts by 10¹⁸ with no error. The note currently says such a chainspec "would need to be restated in XDC" without stating the consequence of not doing so. One clause; no code implied.

The Write <remarks> under-sells the asymmetry. "nothing performs one" is a statement about today's callers; the stronger and durable property is that a round trip cannot silently misread — Write emits a hex string, Read requires JsonTokenType.Number, so a written value is rejected rather than re-scaled. That's what makes the asymmetry defensible and it's worth the half-sentence.

Verification caveat

Static review plus hand arithmetic. dotnet build and python3 were both denied by the tool allowlist in this job, as in the two previous rounds, so I could not compile or run Nethermind.Xdc.Test — the 590/590 and the new fixture are your CI's word, not mine. The conversion arithmetic and the three QUANTITY hex values were verified by hand in the earlier rounds and I did not re-derive them here; nothing in 3a2dde6 touched that code path.

Verdict

Mergeable. Three Low items, all optional, none consensus-relevant.
· branch claude/chainspec-float-compatibility-b4af83

@ak88
ak88 requested review from batrr, flcl42 and hudem1 August 17, 2026 21:03

[DoesNotReturn, StackTraceHidden]
private static void ThrowTooLarge(in Utf8JsonReader reader) =>
throw new JsonException($"'{Literal(in reader)}' XDC exceeds the largest amount representable in wei");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the message names the wrong ceiling, and nothing pins it.

wei is bounded by UInt256, not by wei as a unit — an amount larger than this is perfectly representable in wei, just not in 256 bits. Exact wording would be "…exceeds the largest amount representable as a UInt256 of wei".

Related: the only case exercising this guard is 1e60, a full order of magnitude past the ceiling ((2^256 − 1) / 10^18 ≈ 1.1579e59 XDC). It proves the guard fires, but a pair straddling the boundary would pin where it fires. Optional — the ceiling isn't a value anyone configures, and the guard's real job here is to stop the oversized wei.TryWriteBytes into bytes[(32 - byteCount)..] on line 101, which it does correctly by sitting before it.

@flcl42 flcl42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 high-severity issue in src/Nethermind/Nethermind.Xdc/Spec/XdcToWeiConverter.cs.

ThrowNotAnAmount(in reader);
}

return ToWei(amount, in reader);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Legacy wei-denominated XDC chainspecs are silently rescaled

Before this PR, these UInt256 fields and the module documentation treated numeric values as wei. An existing custom XDPoS or subnet chainspec can therefore contain 63420000000000001704; this path now treats it as XDC and produces roughly 6.342e37 wei, which still fits UInt256, so startup succeeds. Once TIPUpgradeReward is active, that value is minted directly, causing approximately 10^18-overpayments and a state-root split from nodes using the previous interpretation. A migration discriminator or explicit rejection of the legacy representation would prevent the silent unit change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no production configs out there, so this is not a problem

@ak88

ak88 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@claude

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 2m 44s —— View job


Re-review — no code change since the last pass

  • Gather context (branch state, current file contents)
  • Check what changed since the last review round
  • Adjudicate the open disagreement on the wei-migration finding
  • Re-check remaining nits and post findings

HEAD is still f6ae705 — the same commit I reviewed in the ready-for-review pass. No new commits, so that verdict stands unchanged: mergeable, no Critical/High/Medium. Rather than repeat it, here's the one thing that is actually open — the disagreement between @flcl42's HIGH and your reply — plus what I found this round that bears on it.

On the wei-migration finding (@flcl42's HIGH, your "no production configs out there")

The mechanism is real — it's the same finding I raised in round one, and neither of us disputes the code path: a chainspec still stating 63420000000000001704 loads, scales to ~6.342e37 wei, fits UInt256, and inflates payouts by 10¹⁸ with no error. What's in dispute is only whether any such chainspec exists, and that's a deployment fact outside the repo. It's your call as the owner of the XDC integration, and nothing in the tree contradicts it:

  • xdc.json has no MasternodeReward / ProtectorReward / ObserverReward keys — I re-grepped every file under src/Nethermind/Chains/, and xdc-testnet.json is the only chainspec in the repo carrying them.
  • Both shipped chainspecs are embedded and replaced on upgrade, so there is no in-repo migration path to get wrong.

One supporting point I hadn't made before, which I think settles it in your favour more cleanly than the deployment argument does:

engine.XDPoS.params was already XDC-denominated before this PR. xdc.json:8 sets "reward": 5000, and XdcRewardCalculator.cs:81 computes (UInt256)spec.Reward * Unit.Ether — whole XDC, scaled on use. So the same params object already carried an XDC field next to three wei ones, and the README table documented them that way side by side. This PR doesn't introduce a unit convention into that section; it makes the section internally consistent. That's worth a line in the PR description, because it reframes the change from "we moved the unit" to "we fixed the one field group that disagreed with its neighbours" — and it's a better answer to the migration objection than the deployment claim, since it doesn't depend on facts a reviewer can't check.

The Reward precedent is already cited in the converter's <remarks> (line 29-30) — it just isn't in the PR body where a reviewer looks first.

Remaining items — all Low, all optional, all unchanged

  1. Release note doesn't state the consequence of not migrating. With the bound reverted, the text "would need to be restated in XDC" is true but stops one clause short: an un-restated --Init.ChainSpecPath value loads and inflates rewards by 10¹⁸ silently. Even granting that no such config exists today, the release note is the artefact a future operator reads. One clause, no code.
  2. ThrowTooLarge names the wrong ceiling (XdcToWeiConverter.cs:146) — the limit is UInt256, not wei as a unit. "…representable as a UInt256 of wei" would be exact.
  3. No test near the guard's boundary. 1e60 is an order of magnitude past (2^256 − 1) / 10^18 ≈ 1.1579e59. A straddling pair would pin where it fires, not just that it does. Genuinely marginal — nobody configures a value near that ceiling.

Say the word and I'll push 1–3; they're a few lines total and none touch the conversion.

Re-confirmed clean this round

  • V2ConfigParams reward fields are reached by a serializer in exactly two places: the chainspec (Read) and the XDPoS_networkInformation response (Write, delegated to UInt256Converter, wire format unchanged). Everything else — XdcTestBlockchain.cs:224-226, RpcModuleTests.cs:109-111, XdcReleaseSpec, XdcRewardCalculator — sets an already-converted UInt256 through an object initializer, no converter involved. RpcModuleTests sets these on XdcReleaseSpec, which carries no annotation, so it's unaffected.
  • README corrected in both places (line 290/292 prose, line 560 table row, now XDC with the Apothem reference value).

Verification caveat

Static review only. dotnet build was denied by the tool allowlist again in this job, as in all three previous rounds, so I could not compile or run Nethermind.Xdc.Test — CI's word on the suite, not mine. I did not re-derive the conversion arithmetic or the three QUANTITY hex values; both were verified by hand in earlier rounds and nothing has changed since.

Verdict

Ship it. The only thing I'd still ask for is the release-note clause, and that's prose.
· branch claude/chainspec-float-compatibility-b4af83

@ak88
ak88 requested a review from flcl42 August 18, 2026 08:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants