Skip to content
Closed
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/kupmios-plutusv4-byron.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

The Kupmios provider now handles two Kupo responses it previously could not. `Kupo.ScriptSchema` accepts the `plutus:v4` language literal that Kupo v2.12 can return for `/scripts/{hash}`, and `getScript` fails with a `ProviderError` naming the language as not supported yet instead of the response being rejected as an opaque schema parse error (or, once the literal was accepted, a `throw` inside `Effect.map` surfacing as a defect). A UTxO whose address Kupo writes in Byron-era base58 likewise fails with a `ProviderError` explaining that Byron addresses are not supported, instead of the bech32 decode throwing inside `Effect.map` and surfacing as a defect callers cannot handle as a provider failure.
61 changes: 43 additions & 18 deletions packages/evolution/src/sdk/provider/internal/KupmiosEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,29 +128,38 @@ const getScriptEffect =
Effect.flatMap(Effect.fromNullable),
Effect.retry(Schedule.compose(Schedule.exponential(50), Schedule.recurs(5))),
Effect.timeout(5_000),
Effect.map(({ language, script }): CoreScript.Script => {
Effect.flatMap(({ language, script }): Effect.Effect<CoreScript.Script, Provider.ProviderError> => {
// Convert script hex to bytes - Kupo returns scripts already properly encoded
const scriptBytes = Bytes.fromHex(script)

// Create the proper Script type based on language
switch (language) {
case "native": {
// Parse the native script from CBOR
return NativeScripts.fromCBORBytes(scriptBytes)
return Effect.succeed(NativeScripts.fromCBORBytes(scriptBytes))
}
case "plutus:v1": {
// Kupo returns scripts in single CBOR-encoded format (59xxxx...)
// which is the correct format for PlutusV1/V2/V3 bytes field
return new PlutusV1.PlutusV1({ bytes: scriptBytes })
return Effect.succeed(new PlutusV1.PlutusV1({ bytes: scriptBytes }))
}
case "plutus:v2": {
return new PlutusV2.PlutusV2({ bytes: scriptBytes })
return Effect.succeed(new PlutusV2.PlutusV2({ bytes: scriptBytes }))
}
case "plutus:v3": {
return new PlutusV3.PlutusV3({ bytes: scriptBytes })
return Effect.succeed(new PlutusV3.PlutusV3({ bytes: scriptBytes }))
}
default:
throw new Error(`Unknown script language: ${language}`)
// Kupo can return languages this SDK does not model yet —
// "plutus:v4" since Kupo v2.12. Fail with a ProviderError
// instead of throwing, which would surface as a defect that
// callers cannot handle as a provider failure.
return Effect.fail(
new Provider.ProviderError({
message: `Kupmios getScript failed: script language "${language}" is not supported yet`,
cause: new Error(`Unsupported script language: ${language}`)
})
)
}
}),
Effect.catchAll(wrapError("getScript"))
Expand All @@ -168,18 +177,34 @@ const kupmiosUtxosToUtxos =
(utxo) => {
return pipe(
Effect.all([getDatum(utxo.datum_type, utxo.datum_hash), getScript(utxo.script_hash)]),
Effect.map(([datumOption, scriptRef]): CoreUTxO.UTxO => {
const transactionId = TransactionHash.fromHex(utxo.transaction_id)
const address = CoreAddress.fromBech32(utxo.address)
return new CoreUTxO.UTxO({
transactionId,
index: utxo.output_index,
address,
assets: toAssets(utxo.value),
datumOption,
scriptRef
})
})
Effect.flatMap(([datumOption, scriptRef]) =>
// Kupo writes Byron-era addresses in base58, not bech32. Decoding
// one with fromBech32 throws, and a throw inside Effect.map would
// surface as a defect; fail with a ProviderError instead, which
// callers can handle as a provider failure.
Effect.try({
try: () => CoreAddress.fromBech32(utxo.address),
catch: (cause) =>
new Provider.ProviderError({
message:
`Kupmios: cannot decode the address "${utxo.address}" returned by Kupo as a bech32 Shelley ` +
`address. Kupo writes Byron-era addresses in base58, and Byron addresses are not supported`,
cause
})
}).pipe(
Effect.map(
(address): CoreUTxO.UTxO =>
new CoreUTxO.UTxO({
transactionId: TransactionHash.fromHex(utxo.transaction_id),
index: utxo.output_index,
address,
assets: toAssets(utxo.value),
datumOption,
scriptRef
})
)
)
)
)
},
{ concurrency: 10 }
Expand Down
2 changes: 1 addition & 1 deletion packages/evolution/src/sdk/provider/internal/Kupo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface UTxO extends S.Schema.Type<typeof UTxOSchema> {}

export const ScriptSchema = S.NullOr(
S.Struct({
language: S.Literal("native", "plutus:v1", "plutus:v2", "plutus:v3"),
language: S.Literal("native", "plutus:v1", "plutus:v2", "plutus:v3", "plutus:v4"),
script: S.String
})
)
Expand Down
93 changes: 92 additions & 1 deletion packages/evolution/test/provider/Kupmios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"

import { Effect } from "effect"
import { Effect, Exit, Schema } from "effect"
import { afterEach, describe, expect, it, vi } from "vitest"

import * as CoreAddress from "../../src/Address.js"
Expand All @@ -17,6 +17,8 @@ import * as PoolKeyHash from "../../src/PoolKeyHash.js"
import * as RewardAddress from "../../src/RewardAddress.js"
import * as ScriptHash from "../../src/ScriptHash.js"
import * as KupmiosEffects from "../../src/sdk/provider/internal/KupmiosEffects.js"
import * as Kupo from "../../src/sdk/provider/internal/Kupo.js"
import * as Provider from "../../src/sdk/provider/Provider.js"
import * as Transaction from "../../src/Transaction.js"
import * as TransactionHash from "../../src/TransactionHash.js"
import * as TransactionInput from "../../src/TransactionInput.js"
Expand Down Expand Up @@ -292,3 +294,92 @@ describe("Kupmios integers past 2^53", () => {
])
})
})

describe("Kupmios responses the SDK cannot represent", () => {
// A Byron-era address exactly as Kupo writes it: base58, not bech32 (the
// output address in Kupo's golden vector test/vectors/golden/1.json)
const BYRON_ADDRESS = "Ae2tdPwUPEZ834WsZU3C2Fn9vsZF5oGt2SBGXRvNFH8uPkbSScTpMKHxNc9"
const SCRIPT_HASH = "bdc22da682cd9aceed5fd48914789fc98c94abc79fed8b40cb8c4314"

/** Kupo `/matches` body for one UTxO at the given address / script hash */
const matchesWith = (address: string, scriptHash: string | null) => `[{
"transaction_index": 0,
"transaction_id": "${TX_HASH}",
"output_index": 0,
"address": "${address}",
"value": {"coins": 2000000, "assets": {}},
"datum_hash": null,
"script_hash": ${scriptHash === null ? "null" : `"${scriptHash}"`},
"created_at": {"slot_no": 56486181, "header_hash": "d25ac5572bedeba724d8c8178a5b6dcf95b75992970dab7145403cfbc44f9809"},
"spent_at": null
}]`

/** Render an error and its whole `cause` chain; the top message alone is generic */
const renderChain = (error: unknown): string => {
const parts: Array<string> = []
let current: unknown = error
while (current instanceof Error) {
parts.push(`${current.name}: ${current.message}`)
current = (current as { cause?: unknown }).cause
}
return parts.join("\n")
}

it("fails with a ProviderError, not a defect, for a Byron (base58) address", async () => {
stubKupmios({ kupo: { "/matches/": matchesWith(BYRON_ADDRESS, null) } })

const exit = await Effect.runPromiseExit(KupmiosEffects.getUtxosEffect(KUPO_URL)(CoreAddress.fromBech32(ADDRESS)))

expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
// A defect here would be Cause.Die: an unhandled throw, not a provider failure
expect(exit.cause._tag).toBe("Fail")
if (exit.cause._tag === "Fail") {
expect(exit.cause.error).toBeInstanceOf(Provider.ProviderError)
expect(renderChain(exit.cause.error)).toMatch(/Byron/)
expect(renderChain(exit.cause.error)).toMatch(/base58/)
}
}
})

it("decodes the plutus:v4 language literal Kupo v2.12 can return", () => {
const decoded = Schema.decodeUnknownSync(Kupo.ScriptSchema)({ language: "plutus:v4", script: "010203" })

expect(decoded?.language).toBe("plutus:v4")
})

it("fails with a ProviderError naming plutus:v4 as unsupported, not a schema parse error", async () => {
stubKupmios({
kupo: {
"/matches/": matchesWith(ADDRESS, SCRIPT_HASH),
[`/scripts/${SCRIPT_HASH}`]: `{"language":"plutus:v4","script":"010203"}`
}
})

const exit = await Effect.runPromiseExit(KupmiosEffects.getUtxosEffect(KUPO_URL)(CoreAddress.fromBech32(ADDRESS)))

expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(exit.cause._tag).toBe("Fail")
if (exit.cause._tag === "Fail") {
expect(exit.cause.error).toBeInstanceOf(Provider.ProviderError)
expect(renderChain(exit.cause.error)).toMatch(/plutus:v4/)
expect(renderChain(exit.cause.error)).toMatch(/not supported/)
expect(renderChain(exit.cause.error)).not.toMatch(/ParseError|Expected/)
}
}
})

it("still resolves a plutus:v3 reference script", async () => {
stubKupmios({
kupo: {
"/matches/": matchesWith(ADDRESS, SCRIPT_HASH),
[`/scripts/${SCRIPT_HASH}`]: `{"language":"plutus:v3","script":"010203"}`
}
})

const [utxo] = await Effect.runPromise(KupmiosEffects.getUtxosEffect(KUPO_URL)(CoreAddress.fromBech32(ADDRESS)))

expect(utxo!.scriptRef?._tag).toBe("PlutusV3")
})
})