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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions