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
- String-param fix in
core — low effort, fixes the precision bug, removes one fraction.js use. (Worth doing on its own correctness merits.)
- db-sync
numeric → string query results — medium effort, exact, removes another fraction.js use.
- 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).
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,UnitIntervaltag-30 rationals).The bug
In
packages/core/src/Serialization/Update/ProtocolParamUpdate.ts, e.g.:UnitInterval.fromFloat(Serialization/Common/UnitInterval.ts) callsnew 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/dThe 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 typedstringinCardano/types/ProtocolParameters.ts.Proposed fix
Parse the decimal string directly to a reduced rational (
"0.51" → 51/100) instead ofNumber()→fromFloat. This is exact, deterministic, trivial, and self-contained tocore(UnitInterval+ProtocolParamUpdate). Add tests demonstrating the exact-vs-approximate difference.Bonus: removes the
fraction.jsdependency from this path.Related: float-sourced rationals (separate, larger scope)
Other rational params genuinely arrive as
numberat the boundary and still need conversion:dvt_*voting thresholds, pricesnumber(Postgresnumericcoerced to JS float)numericcolumns as strings from the query (lossless), then parse string→rational. Touchescardano-services.margin_costnumber(API returns a float)So fully removing
fraction.jswould require the db-sync change and still leave the blockfrost float path needing approximation (a small local approximator or keepingfraction.jsthere).Scope / suggested sequencing
core— low effort, fixes the precision bug, removes onefraction.jsuse. (Worth doing on its own correctness merits.)numeric → stringquery results — medium effort, exact, removes anotherfraction.jsuse.Context: surfaced during a
@cardano-sdk/coredependency-minimisation review (seedocs/core-dependency-minimisation.md).fraction.js's number constructor isfraction.js@4.2.0lines 151–210 (Stern-Brocot,N=10^7).