From 090c9dba922a8586d0b4fcd0a123f61553e39ede Mon Sep 17 00:00:00 2001 From: Alexander Nemish Date: Tue, 29 Sep 2026 16:20:11 +0200 Subject: [PATCH] feat(scalus-uplc): move to Scalus 1.3.0 and drop the CBOR map builder Scalus reads a UTxO set as CIP-30 [input, output] pairs, so the evaluator passes them straight through instead of assembling a CBOR map with its own map-header writer and byte comparator. Evaluation goes through evaluator.evaluateTx rather than the deprecated Scalus.evalPlutusScripts, at protocol version 11. The transaction's maxTxExMem and maxTxExSteps are now passed as one budget for all its scripts, as the ledger applies them. An unknown redeemer tag raises instead of falling back to "spend" and mis-pricing another script. --- .changeset/scalus-uplc-1-3-0.md | 18 +++ packages/scalus-uplc/package.json | 2 +- packages/scalus-uplc/src/Evaluator.ts | 198 +++++++++----------------- pnpm-lock.yaml | 11 +- 4 files changed, 94 insertions(+), 135 deletions(-) create mode 100644 .changeset/scalus-uplc-1-3-0.md diff --git a/.changeset/scalus-uplc-1-3-0.md b/.changeset/scalus-uplc-1-3-0.md new file mode 100644 index 00000000..a5369d62 --- /dev/null +++ b/.changeset/scalus-uplc-1-3-0.md @@ -0,0 +1,18 @@ +--- +"@evolution-sdk/scalus-uplc": minor +--- + +Move to Scalus 1.3.0 and evaluate through its CBOR-first API. + +Scalus reads a UTxO set as CIP-30 `[input, output]` pairs, so the evaluator hands them over +directly instead of assembling a CBOR map with a hand-written map-header writer. Evaluation goes +through `evaluator.evaluateTx` rather than the deprecated `Scalus.evalPlutusScripts`, at protocol +version 11. + +The evaluator now also passes the transaction's `maxTxExMem` / `maxTxExSteps` as one budget for all +its scripts, as the ledger applies them, so a transaction whose scripts do not fit fails at build +time rather than on submission. + +An unknown redeemer tag is an error instead of defaulting to `"spend"`, which would have mis-priced +a different script. The "zero execution units means failure" check is gone: a failing script raises +an evaluation error, so zero units no longer needs to stand in for one. diff --git a/packages/scalus-uplc/package.json b/packages/scalus-uplc/package.json index 35c8ded9..163e6af6 100644 --- a/packages/scalus-uplc/package.json +++ b/packages/scalus-uplc/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "effect": "^3.21.1", - "scalus": "^0.17.0" + "scalus": "^1.3.0" }, "peerDependencies": { "@evolution-sdk/evolution": "workspace:*" diff --git a/packages/scalus-uplc/src/Evaluator.ts b/packages/scalus-uplc/src/Evaluator.ts index 730700a6..9e7f2564 100644 --- a/packages/scalus-uplc/src/Evaluator.ts +++ b/packages/scalus-uplc/src/Evaluator.ts @@ -1,6 +1,4 @@ -import * as Bytes from "@evolution-sdk/evolution/Bytes" import * as CBOR from "@evolution-sdk/evolution/CBOR" -import type * as CostModel from "@evolution-sdk/evolution/CostModel" import * as Redeemer from "@evolution-sdk/evolution/Redeemer" import * as Script from "@evolution-sdk/evolution/Script" import * as ScriptRef from "@evolution-sdk/evolution/ScriptRef" @@ -11,47 +9,44 @@ import * as TransactionInput from "@evolution-sdk/evolution/TransactionInput" import * as TxOut from "@evolution-sdk/evolution/TxOut" import type * as UTxO from "@evolution-sdk/evolution/UTxO" import { Effect, Schema } from "effect" -import ScalusLib from "scalus" +import { evaluator } from "scalus" + +/** EvaluationContext carries no protocol version, so cost against mainnet's (van Rossem). */ +const PROTOCOL_MAJOR_VERSION = 11 + +/** Scalus names a redeemer's purpose as the ledger CDDL does; Evolution uses lower case. */ +const TAGS: Record = { + Spend: "spend", + Mint: "mint", + Cert: "cert", + Reward: "reward", + Voting: "vote", + Proposing: "propose" +} /** - * Build CBOR-encoded map of TransactionInput → TransactionOutput from UTxOs. + * The UTxO as CIP-30's `[input, output]` pair, which `evaluator.evaluateTx` reads directly. * - * Uses FromCDDL schemas to get CBOR values directly, avoiding wasteful - * bytes → CBOR → bytes roundtrip encoding. + * FromCDDL gives the CBOR values without a bytes round trip, so the pair is built from the + * transaction types rather than re-encoded. */ -function buildUtxoMapCBOR(utxos: ReadonlyArray): Uint8Array { - const utxoMap = new Map() - - for (const utxo of utxos) { - // Use FromCDDL to get CBOR values directly (no double encoding) - const txInput = new TransactionInput.TransactionInput({ - transactionId: utxo.transactionId, - index: utxo.index - }) - const inputCBOR = Schema.encodeSync(TransactionInput.FromCDDL)(txInput) - - const scriptRef = utxo.scriptRef ? new ScriptRef.ScriptRef({ bytes: Script.toCBOR(utxo.scriptRef) }) : undefined - const txOut = new TxOut.TransactionOutput({ - address: utxo.address, - assets: utxo.assets, - datumOption: utxo.datumOption, - scriptRef - }) - const outputCBOR = Schema.encodeSync(TxOut.FromCDDL)(txOut) - - utxoMap.set(inputCBOR, outputCBOR) - } - - return CBOR.toCBORBytes(utxoMap, CBOR.CML_DEFAULT_OPTIONS) -} - -function decodeCostModels(costModels: CostModel.CostModels): Array> { - // Scalus expects a flattened representation of the cost models as number arrays - const plutusV1 = costModels.PlutusV1.costs.map((c: bigint) => Number(c)) - const plutusV2 = costModels.PlutusV2.costs.map((c: bigint) => Number(c)) - const plutusV3 = costModels.PlutusV3.costs.map((c: bigint) => Number(c)) - return [plutusV1, plutusV2, plutusV3] -} +const toPair = (utxo: UTxO.UTxO): Uint8Array => + CBOR.toCBORBytes( + [ + Schema.encodeSync(TransactionInput.FromCDDL)( + new TransactionInput.TransactionInput({ transactionId: utxo.transactionId, index: utxo.index }) + ), + Schema.encodeSync(TxOut.FromCDDL)( + new TxOut.TransactionOutput({ + address: utxo.address, + assets: utxo.assets, + datumOption: utxo.datumOption, + scriptRef: utxo.scriptRef ? new ScriptRef.ScriptRef({ bytes: Script.toCBOR(utxo.scriptRef) }) : undefined + }) + ) + ], + CBOR.CML_DEFAULT_OPTIONS + ) export function makeEvaluator(): TransactionBuilder.Evaluator { return { @@ -60,98 +55,43 @@ export function makeEvaluator(): TransactionBuilder.Evaluator { additionalUtxos: ReadonlyArray | undefined, context: TransactionBuilder.EvaluationContext ) => - Effect.gen(function* () { - yield* Effect.logDebug("[Scalus UPLC] Starting evaluation") - - // Serialize transaction to CBOR bytes - const txBytes = Transaction.toCBORBytes(tx) - - yield* Effect.logDebug(`[Scalus UPLC] Transaction CBOR bytes: ${txBytes.length}`) - - const utxos = additionalUtxos ?? [] - yield* Effect.logDebug(`[Scalus UPLC] Additional UTxOs: ${utxos.length}`) - - // Build UTxO map CBOR - const utxosBytes = buildUtxoMapCBOR(utxos) - yield* Effect.logDebug(`[Scalus UPLC] UTxO map CBOR bytes: ${utxosBytes.length}`) - yield* Effect.logDebug(`[Scalus UPLC] UTxO map CBOR hex: ${Bytes.toHex(utxosBytes)}`) - - const { slotLength, zeroSlot, zeroTime } = context.slotConfig - - yield* Effect.logDebug( - `[Scalus UPLC] Slot config - zeroTime: ${zeroTime}, zeroSlot: ${zeroSlot}, slotLength: ${slotLength}` - ) - - const costModels: Array> = decodeCostModels(context.costModels) - yield* Effect.logDebug( - `[Scalus UPLC] Cost models - V1: ${costModels[0].length}, V2: ${costModels[1].length}, V3: ${costModels[2].length} costs` - ) - yield* Effect.logDebug( - `[Scalus UPLC] Max execution - steps: ${context.maxTxExSteps}, mem: ${context.maxTxExMem}` - ) - - // Scalus-specific slot config - const slotConfig = new ScalusLib.SlotConfig(Number(zeroTime), Number(zeroSlot), slotLength) - - yield* Effect.logDebug("[Scalus UPLC] Calling evalPlutusScripts...") - const redeemers = yield* Effect.try({ - try: () => ScalusLib.Scalus.evalPlutusScripts(txBytes, utxosBytes, slotConfig, costModels), - catch: (error) => { - // Scalus error messages and evaluation logs, if any, are available to form an exception - const errorObj = error as any - const msg: string = errorObj?.message ?? "Unknown evaluation error" - - return new TransactionBuilder.EvaluationError({ - cause: error, - message: msg, - failures: [] - }) - } - }) - - yield* Effect.logDebug(`[Scalus UPLC] Evaluation successful - ${redeemers.length} redeemer(s) returned`) - - // Check if redeemers array is empty - if (redeemers.length === 0) { - return yield* new TransactionBuilder.EvaluationError({ - message: "Scalus evaluation returned no redeemers", - failures: [] - }) - } - - // Transform Scalus redeemers to Evolution format and check for zero execution units - const evalRedeemers: Array = [] - for (const r of redeemers) { - const mem = BigInt(r.budget.memory) - const steps = BigInt(r.budget.steps) - - // Check if execution units are zero (indicates evaluation failure) - if (mem === 0n && steps === 0n) { - return yield* Effect.fail( - new TransactionBuilder.EvaluationError({ - message: `Scalus evaluation returned zero execution units for redeemer ${r.tag}:${r.index}`, - failures: [] - }) + Effect.try({ + try: () => + evaluator + .evaluateTx( + Transaction.toCBORBytes(tx), + (additionalUtxos ?? []).map(toPair), + context.slotConfig, + { + PlutusV1: context.costModels.PlutusV1.costs, + PlutusV2: context.costModels.PlutusV2.costs, + PlutusV3: context.costModels.PlutusV3.costs + }, + PROTOCOL_MAJOR_VERSION, + // The ledger limits a transaction's scripts together, so the evaluator does too: one + // that does not fit fails here rather than on submission. + { memory: context.maxTxExMem, steps: context.maxTxExSteps } ) - } - - const tagMap: Record = { - Spend: "spend", - Mint: "mint", - Cert: "cert", - Reward: "reward", - Voting: "vote", - Proposing: "propose" - } - - evalRedeemers.push({ - redeemer_tag: tagMap[r.tag] || "spend", - redeemer_index: r.index, - ex_units: new Redeemer.ExUnits({ mem, steps }) + .map((redeemer): EvalRedeemer.EvalRedeemer => { + const tag = TAGS[redeemer.tag] + // An unknown tag is an error, not a default: a tag added upstream must not silently + // become a spend redeemer and mis-price a different script. + if (!tag) throw new Error(`Unknown Scalus redeemer tag "${redeemer.tag}"`) + return { + redeemer_tag: tag, + redeemer_index: redeemer.index, + ex_units: new Redeemer.ExUnits({ + mem: BigInt(redeemer.budget.memory), + steps: BigInt(redeemer.budget.steps) + }) + } + }), + catch: (error) => + new TransactionBuilder.EvaluationError({ + cause: error, + message: error instanceof Error ? error.message : "Unknown evaluation error", + failures: [] }) - } - - return evalRedeemers }) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67df4444..deb4091f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,8 +329,8 @@ importers: specifier: ^3.21.1 version: 3.22.2 scalus: - specifier: ^0.17.0 - version: 0.17.0 + specifier: ^1.3.0 + version: 1.3.0 devDependencies: typescript: specifier: ^6.0.3 @@ -5908,8 +5908,9 @@ packages: engines: {node: '>=18'} hasBin: true - scalus@0.17.0: - resolution: {integrity: sha512-74mD4wL1vw4GVh2ECemQhoB3q5Smwj5S8W7OG1j7OWuLkwJ0Mvz4LFhZPA9rZjpNvanZoKiDqc3S/qGD65uRYg==} + scalus@1.3.0: + resolution: {integrity: sha512-D2Z+wuQS1hEr2r2nWRkhvI2If9n8PdKH8/PpnP+ijo+mkDDKOprU5GvjccH9F7LBEfj8uCY73wBc2wSWu+MA2w==} + engines: {node: '>=18'} scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} @@ -12730,7 +12731,7 @@ snapshots: commander: 12.1.0 enhanced-resolve: 5.24.3 - scalus@0.17.0: {} + scalus@1.3.0: {} scheduler@0.28.0: {}