From 81b35241fd0be38e13f180d5d120657b014f371a Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Wed, 14 Oct 2026 16:59:27 +0000 Subject: [PATCH] fix(provider): rebuild Koios native reference scripts from their JSON value (#571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toUTxO only set scriptRef when reference_script.bytes was present, and its switch handled only the timelock native type — but db-sync stores no CBOR for native scripts before the Dijkstra era, so Koios returns bytes: null for timelock and multisig reference scripts and those UTxOs came back with no scriptRef and no error; multisig was dropped even when bytes were present. Native scripts with null bytes are now rebuilt recursively from reference_script.value (the db-sync script.json, decoded through the existing NativeJSONSchema) and checked against the hash Koios reports: a mismatch, or a value that does not decode as a native script, throws a clear error instead of silently dropping the script. multisig joins timelock on the bytes path. Five new tests, each verified red on unmodified main. Koios file 34/34; full suite 1683 passed / 75 skipped / 0 failed. --- .changeset/koios-native-reference-scripts.md | 5 + .../src/sdk/provider/internal/Koios.ts | 85 ++++++++++++++--- .../test/provider/KoiosLovelace.test.ts | 92 +++++++++++++++++++ 3 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 .changeset/koios-native-reference-scripts.md diff --git a/.changeset/koios-native-reference-scripts.md b/.changeset/koios-native-reference-scripts.md new file mode 100644 index 00000000..beab3641 --- /dev/null +++ b/.changeset/koios-native-reference-scripts.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Koios UTxOs no longer lose their native reference scripts. db-sync stores no CBOR for native scripts before the Dijkstra era, so Koios returns `reference_script.bytes: null` for timelock and multisig scripts and `toUTxO` silently came back with no `scriptRef`. Native scripts are now rebuilt from the script JSON in `reference_script.value` and checked against the `hash` Koios reports — a hash mismatch or a value that does not decode as a native script throws a clear error instead of dropping the script. The `multisig` type, which the switch did not handle at all, is also decoded from bytes when they are present. diff --git a/packages/evolution/src/sdk/provider/internal/Koios.ts b/packages/evolution/src/sdk/provider/internal/Koios.ts index d9637f87..9b5017ad 100644 --- a/packages/evolution/src/sdk/provider/internal/Koios.ts +++ b/packages/evolution/src/sdk/provider/internal/Koios.ts @@ -8,11 +8,13 @@ import * as PlutusData from "../../../Data.js" import * as DatumHash from "../../../DatumHash.js" import type * as DatumOption from "../../../DatumOption.js" import * as InlineDatum from "../../../InlineDatum.js" +import * as NativeScriptJSON from "../../../NativeScriptJSON.js" import * as NativeScripts from "../../../NativeScripts.js" import * as PlutusV1 from "../../../PlutusV1.js" import * as PlutusV2 from "../../../PlutusV2.js" import * as PlutusV3 from "../../../PlutusV3.js" import type * as Script from "../../../Script.js" +import * as ScriptHash from "../../../ScriptHash.js" import * as TransactionHash from "../../../TransactionHash.js" import * as CoreUTxO from "../../../UTxO.js" import * as HttpUtils from "./HttpUtils.js" @@ -173,6 +175,52 @@ export const getHeadersWithToken = (token?: string, headers: Record { + switch (json.type) { + case "sig": + return { _tag: "ScriptPubKey", keyHash: Bytes.fromHex(json.keyHash) } + case "after": + return { _tag: "InvalidBefore", slot: BigInt(json.slot) } + case "before": + return { _tag: "InvalidHereafter", slot: BigInt(json.slot) } + case "all": + return { _tag: "ScriptAll", scripts: json.scripts.map(nativeVariantFromJSON) } + case "any": + return { _tag: "ScriptAny", scripts: json.scripts.map(nativeVariantFromJSON) } + case "atLeast": + return { _tag: "ScriptNOfK", required: BigInt(json.required), scripts: json.scripts.map(nativeVariantFromJSON) } + } +} + +/** + * Rebuild a native reference script from the `value` Koios returns for it. + * + * db-sync stores no CBOR for native scripts before the Dijkstra era, so Koios + * sends `bytes: null` for timelock and multisig reference scripts; the script + * JSON in `value` (the db-sync `script.json`) is the only form available. The + * rebuilt script is checked against the `hash` Koios reports: a mismatch, or a + * value that does not decode as a native script, throws instead of silently + * dropping or corrupting the script reference. + */ +export const nativeScriptFromKoiosValue = (rs: ReferenceScript): NativeScripts.NativeScript => { + let script: NativeScripts.NativeScript + try { + const json = Schema.decodeSync(NativeScriptJSON.NativeJSONSchema)(rs.value) + script = new NativeScripts.NativeScript({ script: nativeVariantFromJSON(json) }) + } catch (cause) { + throw new Error("Koios native reference script could not be rebuilt from its value", { cause }) + } + if (rs.hash) { + const actual = ScriptHash.toHex(ScriptHash.fromScript(script)) + if (actual !== rs.hash) { + throw new Error( + `Koios native reference script hash mismatch: rebuilt script hashes to ${actual}, but Koios reported ${rs.hash}` + ) + } + } + return script +} + export const toUTxO = (koiosUTxO: UTxO, addressStr: string): CoreUTxO.UTxO => { // Build Core Assets let assets = CoreAssets.fromLovelace(koiosUTxO.value) @@ -196,21 +244,28 @@ export const toUTxO = (koiosUTxO: UTxO, addressStr: string): CoreUTxO.UTxO => { let scriptRef: Script.Script | undefined const rs = koiosUTxO.reference_script - if (rs?.bytes && rs.type) { - const scriptBytes = Bytes.fromHex(rs.bytes) - switch (rs.type) { - case "plutusV1": - scriptRef = new PlutusV1.PlutusV1({ bytes: scriptBytes }) - break - case "plutusV2": - scriptRef = new PlutusV2.PlutusV2({ bytes: scriptBytes }) - break - case "plutusV3": - scriptRef = new PlutusV3.PlutusV3({ bytes: scriptBytes }) - break - case "timelock": - scriptRef = NativeScripts.fromCBORHex(rs.bytes) - break + if (rs?.type) { + if (rs.bytes) { + const scriptBytes = Bytes.fromHex(rs.bytes) + switch (rs.type) { + case "plutusV1": + scriptRef = new PlutusV1.PlutusV1({ bytes: scriptBytes }) + break + case "plutusV2": + scriptRef = new PlutusV2.PlutusV2({ bytes: scriptBytes }) + break + case "plutusV3": + scriptRef = new PlutusV3.PlutusV3({ bytes: scriptBytes }) + break + case "timelock": + case "multisig": + scriptRef = NativeScripts.fromCBORHex(rs.bytes) + break + } + } else if (rs.type === "timelock" || rs.type === "multisig") { + // No CBOR is stored for native scripts before the Dijkstra era, so + // Koios sends bytes: null — rebuild the script from its JSON value. + scriptRef = nativeScriptFromKoiosValue(rs) } } diff --git a/packages/evolution/test/provider/KoiosLovelace.test.ts b/packages/evolution/test/provider/KoiosLovelace.test.ts index 58d85c7e..30c48c77 100644 --- a/packages/evolution/test/provider/KoiosLovelace.test.ts +++ b/packages/evolution/test/provider/KoiosLovelace.test.ts @@ -13,8 +13,10 @@ import * as CoreAddress from "../../src/Address.js" import * as CoreAssets from "../../src/Assets.js" import * as PlutusData from "../../src/Data.js" import * as DatumHash from "../../src/DatumHash.js" +import * as NativeScripts from "../../src/NativeScripts.js" import * as RewardAddress from "../../src/RewardAddress.js" import * as ScriptHash from "../../src/ScriptHash.js" +import * as Koios from "../../src/sdk/provider/internal/Koios.js" import * as KoiosEffect from "../../src/sdk/provider/internal/KoiosEffect.js" import * as Transaction from "../../src/Transaction.js" import * as TransactionHash from "../../src/TransactionHash.js" @@ -362,3 +364,93 @@ describe("Koios integers past 2^53", () => { ]) }) }) + +describe("Koios native reference scripts (#571)", () => { + const ADDRESS = "addr_test1wz7uytdxstxe4nhdtl2gj9rcnlyce99tc707mz6qewxyx9qac0urr" + const KEY_HASH_A = "11111111111111111111111111111111111111111111111111111111" + const KEY_HASH_B = "22222222222222222222222222222222222222222222222222222222" + // Hand-encoded CBOR (oracle): all [ sig A, any [ sig B, before 5000 ] ] + const ALL_CBOR = + "8201828200581c111111111111111111111111111111111111111111111111111111118202828200581c222222222222222222222222222222222222222222222222222222228205191388" + // blake2b-224 over 0x00 || ALL_CBOR, computed independently of the SDK + const ALL_HASH = "1e56db9744a29f4e9816340ea3a9400d8cab7acb9db37e5f7d7c1167" + const SIG_A_CBOR = "8200581c11111111111111111111111111111111111111111111111111111111" + const SIG_A_HASH = "5334c8c55ec6518f2e262da617d3170bf0722f43813d64dbaf0ff0b9" + const ALL_VALUE = { + type: "all", + scripts: [ + { type: "sig", keyHash: KEY_HASH_A }, + { + type: "any", + scripts: [ + { type: "sig", keyHash: KEY_HASH_B }, + { type: "before", slot: 5000 } + ] + } + ] + } + + const utxoWith = (reference_script: Koios.ReferenceScript | null): Koios.UTxO => ({ + tx_hash: "708cde0e74986ea1f5a9621dc9fc4c0042d83833fa708b7e2fa66f120af72331", + tx_index: 0n, + block_time: 0n, + block_height: null, + value: 2000000n, + datum_hash: null, + inline_datum: null, + reference_script, + asset_list: [] + }) + + it("rebuilds a timelock reference script from its value when bytes are null", () => { + const utxo = Koios.toUTxO( + utxoWith({ hash: ALL_HASH, size: 74n, type: "timelock", bytes: null, value: ALL_VALUE }), + ADDRESS + ) + + expect(utxo.scriptRef).toBeInstanceOf(NativeScripts.NativeScript) + expect(NativeScripts.toCBORHex(utxo.scriptRef as NativeScripts.NativeScript)).toBe(ALL_CBOR) + expect(ScriptHash.toHex(ScriptHash.fromScript(utxo.scriptRef!))).toBe(ALL_HASH) + }) + + it("rebuilds a multisig reference script from its value when bytes are null", () => { + const utxo = Koios.toUTxO( + utxoWith({ + hash: SIG_A_HASH, + size: 32n, + type: "multisig", + bytes: null, + value: { type: "sig", keyHash: KEY_HASH_A } + }), + ADDRESS + ) + + expect(utxo.scriptRef).toBeInstanceOf(NativeScripts.NativeScript) + expect(NativeScripts.toCBORHex(utxo.scriptRef as NativeScripts.NativeScript)).toBe(SIG_A_CBOR) + }) + + it("decodes a multisig reference script from bytes when they are present", () => { + const utxo = Koios.toUTxO( + utxoWith({ hash: SIG_A_HASH, size: 32n, type: "multisig", bytes: SIG_A_CBOR, value: null }), + ADDRESS + ) + + expect(utxo.scriptRef).toBeInstanceOf(NativeScripts.NativeScript) + expect(NativeScripts.toCBORHex(utxo.scriptRef as NativeScripts.NativeScript)).toBe(SIG_A_CBOR) + }) + + it("throws when the rebuilt script does not match the reported hash", () => { + expect(() => + Koios.toUTxO(utxoWith({ hash: SIG_A_HASH, size: 74n, type: "timelock", bytes: null, value: ALL_VALUE }), ADDRESS) + ).toThrow(/hash mismatch/) + }) + + it("throws instead of silently dropping a native script whose value is malformed", () => { + expect(() => + Koios.toUTxO( + utxoWith({ hash: SIG_A_HASH, size: 32n, type: "timelock", bytes: null, value: { type: "sig" } }), + ADDRESS + ) + ).toThrow(/native reference script/) + }) +})