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: {}