Skip to content

Protocol-param string values are degraded to float before rational conversion (precision loss) #1726

Description

@rhyslbw

Summary

Several protocol parameters are modelled as exact decimal strings in the domain types, but core's serialization degrades them to a float and then re-approximates a rational from that float. The round-trip is lossy and feeds consensus-affecting CBOR (protocol-param updates, UnitInterval tag-30 rationals).

The bug

In packages/core/src/Serialization/Update/ProtocolParamUpdate.ts, e.g.:

// parametersUpdate.poolInfluence is typed `string` (see Cardano/types/ProtocolParameters.ts)
UnitInterval.fromFloat(Number(parametersUpdate.poolInfluence))

UnitInterval.fromFloat (Serialization/Common/UnitInterval.ts) calls new Fraction(number), whose single-arg path is a Stern-Brocot best-rational-approximation (fraction.js), not an exact parse. So the pipeline is:

"0.51" → Number("0.51") → Stern-Brocot approximation → n/d

The input string is already exact, so the Number() hop throws away precision and the approximation can land on a different rational than the value denotes — which then serializes to CBOR.

Affected string params (degraded via Number(...)fromFloat)

  • poolInfluence, monetaryExpansion, treasuryExpansion, decentralizationParameter, minFeeRefScriptCostPerByte — all typed string in Cardano/types/ProtocolParameters.ts.

Proposed fix

Parse the decimal string directly to a reduced rational ("0.51" → 51/100) instead of Number()fromFloat. This is exact, deterministic, trivial, and self-contained to core (UnitInterval + ProtocolParamUpdate). Add tests demonstrating the exact-vs-approximate difference.

Bonus: removes the fraction.js dependency from this path.

Related: float-sourced rationals (separate, larger scope)

Other rational params genuinely arrive as number at the boundary and still need conversion:

Source Field(s) Type Can it be made exact?
db-sync dvt_* voting thresholds, prices number (Postgres numeric coerced to JS float) Yes — return the numeric columns as strings from the query (lossless), then parse string→rational. Touches cardano-services.
blockfrost margin_cost number (API returns a float) No — the API hands you a float; the exact rational can't be recovered without approximation. This path fundamentally needs a float→rational step.

So fully removing fraction.js would require the db-sync change and still leave the blockfrost float path needing approximation (a small local approximator or keeping fraction.js there).

Scope / suggested sequencing

  1. String-param fix in core — low effort, fixes the precision bug, removes one fraction.js use. (Worth doing on its own correctness merits.)
  2. db-sync numeric → string query results — medium effort, exact, removes another fraction.js use.
  3. Decide on the blockfrost float path (accept approximation, or push for rational upstream).

Context: surfaced during a @cardano-sdk/core dependency-minimisation review (see docs/core-dependency-minimisation.md). fraction.js's number constructor is fraction.js@4.2.0 lines 151–210 (Stern-Brocot, N=10^7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions