Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/koios-native-reference-scripts.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 70 additions & 15 deletions packages/evolution/src/sdk/provider/internal/Koios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -173,6 +175,52 @@ export const getHeadersWithToken = (token?: string, headers: Record<string, stri
return headers
}

const nativeVariantFromJSON = (json: NativeScriptJSON.NativeJSON): NativeScripts.NativeScriptVariants => {
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)
Expand All @@ -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)
}
}

Expand Down
92 changes: 92 additions & 0 deletions packages/evolution/test/provider/KoiosLovelace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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/)
})
})