Summary
The Kupo provider decodes on-chain lovelace and asset quantities as JS numbers, but a JS
number is exact only to 2^53-1. Cardano amounts are uint64, so any value above 2^53 is
silently rounded. Kupo returns these amounts as unquoted JSON integers, so the rounding
happens at JSON.parse time — before the schema — and the later BigInt(...) conversion just
preserves the already-corrupted value. Blockfrost and Koios avoid this on decode by typing the
same fields as strings. Fail closed: no fund loss; a consumer can display a wrong balance, and a
corrupted UTxO fed into evaluation produces a tx that is rejected on-chain.
Affected
packages/evolution/src/sdk/provider/internal/Kupo.ts
- L4 ValueSchema coins: S.Number
- L5 ValueSchema assets value: S.Number
- L49-50 Delegation rewards/deposit lovelace: S.Number
packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts
- L79 BigInt(value.coins) // wraps an already-rounded number
- L85 BigInt(value.assets[unit]) // wraps an already-rounded number
transport: HttpUtils.get -> response.json -> JSON.parse truncates above 2^53 before the schema runs.
Kupo API: value.coins and asset quantities are type: integer (unquoted JSON numbers), per the Kupo OpenAPI spec.
contrast (correct): Blockfrost.ts inbound quantity: Schema.String -> BigInt; Koios.ts value/quantity: Schema.String -> BigInt.
Fix
Because JSON.parse rounds before the schema, changing S.Number to a bigint schema is not
enough — the inbound amount fields need a big-int-aware JSON parser on the Kupo response
(json-bigint style, or parse coins/assets from the raw text), then carry them as bigint into
CoreAssets.fromLovelace / addByHex (drop the intermediate JS number entirely).
Regression test
- given: a Kupo matches response with coins = 2^53+1 and an asset quantity = 2^64-1 as raw JSON integers
- before fix: decoded amounts are corrupted (2^53+1 -> 2^53, 2^64-1 -> 18446744073709552000)
- after fix: CoreAssets holds the exact bigint values, round-tripping unchanged
Must FAIL on main today and PASS after the fix.
Reference
Reported informally (large-value UTxO precision loss). Related: #406 (same bug class, Ogmios outbound path).
Correction to the original triage: the report also named Blockfrost, and that is partly right —
Blockfrost is safe on decode (amounts are strings) but IS affected on its OUTBOUND evaluate
serialization (BlockfrostEffect.ts toBlockfrostValue, Number() at L89/L98). That Blockfrost
outbound path is a separate finding, tracked in its own issue.
Summary
The Kupo provider decodes on-chain lovelace and asset quantities as JS numbers, but a JS
number is exact only to 2^53-1. Cardano amounts are uint64, so any value above 2^53 is
silently rounded. Kupo returns these amounts as unquoted JSON integers, so the rounding
happens at JSON.parse time — before the schema — and the later BigInt(...) conversion just
preserves the already-corrupted value. Blockfrost and Koios avoid this on decode by typing the
same fields as strings. Fail closed: no fund loss; a consumer can display a wrong balance, and a
corrupted UTxO fed into evaluation produces a tx that is rejected on-chain.
Affected
packages/evolution/src/sdk/provider/internal/Kupo.ts
packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts
transport: HttpUtils.get -> response.json -> JSON.parse truncates above 2^53 before the schema runs.
Kupo API: value.coins and asset quantities are
type: integer(unquoted JSON numbers), per the Kupo OpenAPI spec.contrast (correct): Blockfrost.ts inbound quantity: Schema.String -> BigInt; Koios.ts value/quantity: Schema.String -> BigInt.
Fix
Because JSON.parse rounds before the schema, changing S.Number to a bigint schema is not
enough — the inbound amount fields need a big-int-aware JSON parser on the Kupo response
(json-bigint style, or parse coins/assets from the raw text), then carry them as bigint into
CoreAssets.fromLovelace / addByHex (drop the intermediate JS number entirely).
Regression test
Must FAIL on main today and PASS after the fix.
Reference
Reported informally (large-value UTxO precision loss). Related: #406 (same bug class, Ogmios outbound path).
Correction to the original triage: the report also named Blockfrost, and that is partly right —
Blockfrost is safe on decode (amounts are strings) but IS affected on its OUTBOUND evaluate
serialization (BlockfrostEffect.ts toBlockfrostValue, Number() at L89/L98). That Blockfrost
outbound path is a separate finding, tracked in its own issue.