From 8aead4b91c5ecc0919d4448fa6780dc9a0e1abae Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 29 Dec 2025 15:46:17 +0100 Subject: [PATCH 1/5] feat: scalus --- packages/scalus-uplc/package.json | 32 ++++++ packages/scalus-uplc/src/Evaluator.ts | 133 ++++++++++++++++++++++ packages/scalus-uplc/src/index.browser.ts | 16 +++ packages/scalus-uplc/src/index.node.ts | 16 +++ packages/scalus-uplc/tsconfig.build.json | 10 ++ packages/scalus-uplc/tsconfig.json | 8 ++ packages/scalus-uplc/tsconfig.src.json | 10 ++ packages/scalus-uplc/tsconfig.test.json | 16 +++ pnpm-lock.yaml | 65 ++++++++--- 9 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 packages/scalus-uplc/package.json create mode 100644 packages/scalus-uplc/src/Evaluator.ts create mode 100644 packages/scalus-uplc/src/index.browser.ts create mode 100644 packages/scalus-uplc/src/index.node.ts create mode 100644 packages/scalus-uplc/tsconfig.build.json create mode 100644 packages/scalus-uplc/tsconfig.json create mode 100644 packages/scalus-uplc/tsconfig.src.json create mode 100644 packages/scalus-uplc/tsconfig.test.json diff --git a/packages/scalus-uplc/package.json b/packages/scalus-uplc/package.json new file mode 100644 index 00000000..34d86087 --- /dev/null +++ b/packages/scalus-uplc/package.json @@ -0,0 +1,32 @@ +{ + "name": "@evolution-sdk/scalus-uplc", + "version": "0.0.1", + "description": "Scalus UPLC evaluator adapter for Evolution SDK", + "type": "module", + "exports": { + ".": { + "types": "./src/index.node.ts", + "node": "./src/index.node.ts", + "default": "./src/index.node.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.build.json", + "dev": "tsc -b tsconfig.build.json --watch", + "type-check": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist .turbo .tsbuildinfo" + }, + "dependencies": { + "effect": "^3.19.3", + "scalus": "^0.14.0" + }, + "peerDependencies": { + "@evolution-sdk/evolution": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.2", + "@effect/vitest": "^0.19.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/scalus-uplc/src/Evaluator.ts b/packages/scalus-uplc/src/Evaluator.ts new file mode 100644 index 00000000..9556e5cc --- /dev/null +++ b/packages/scalus-uplc/src/Evaluator.ts @@ -0,0 +1,133 @@ +import * as Bytes from "@evolution-sdk/evolution/core/Bytes" +import * as Transaction from "@evolution-sdk/evolution/core/Transaction" +import type * as UTxO from "@evolution-sdk/evolution/core/UTxO" +import * as TransactionBuilder from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder" +import type * as EvalRedeemer from "@evolution-sdk/evolution/sdk/EvalRedeemer" +import { Effect } from "effect" +import * as Scalus from "scalus" + +/** + * Parse Scalus error string into ScriptFailure array. + */ +function parseScalusError(error: unknown): Array { + const failures: Array = [] + + // Check if it's a PlutusScriptEvaluationException with logs + if (error && typeof error === "object" && "logs" in error) { + const logs = (error as any).logs || [] + const errorMessage = error instanceof Error ? error.message : String(error) + + // TODO: Parse error message for structured failures + // For rough draft: simple fallback + failures.push({ + purpose: "unknown", + index: 0, + validationError: errorMessage, + traces: logs + }) + } else { + const errorMessage = error instanceof Error ? error.message : String(error) + failures.push({ + purpose: "unknown", + index: 0, + validationError: errorMessage, + traces: [] + }) + } + + return failures +} + +/** + * Build CBOR-encoded UTxO map for Scalus. + * Scalus expects: Map[TransactionInput, TransactionOutput] + */ +function buildUtxoMapCBOR(utxos: ReadonlyArray): Uint8Array { + // lucid specific way to encode the utxos as CBOR -- a util most likely exists + return new Uint8Array([0xa0]) +} + +/** + * Decode CBOR cost models to array format. + */ +function decodeCostModels(context: TransactionBuilder.EvaluationContext): Array> { + const plutusV1 = [] + const plutusV2 = [] + const plutusV3 = [] + return [plutusV1, plutusV2, plutusV3] // PlutusV1, V2, V3 +} + +/** + * Create Scalus evaluator + */ +export function makeEvaluator(): TransactionBuilder.Evaluator { + return { + evaluate: ( + tx: Transaction.Transaction, + additionalUtxos: ReadonlyArray | undefined, + context: TransactionBuilder.EvaluationContext + ) => + Effect.gen(function* () { + // Serialize transaction to CBOR bytes + const txBytes = Transaction.toCBORBytes(tx) + const utxos = additionalUtxos ?? [] + // Build UTxO map CBOR + const utxosBytes = buildUtxoMapCBOR(utxos) + const { slotLength, zeroSlot, zeroTime } = context.slotConfig + + const costModels = decodeCostModels(context) + + // Scalus-specific slot config + const slotConfig = new Scalus.SlotConfig( + Number(zeroTime), + Number(zeroSlot), + slotLength + ) + + const redeemers = yield* Effect.try({ + try: () => + Scalus.Scalus.evalPlutusScripts( + Array.from(txBytes), + Array.from(utxosBytes), + slotConfig, + costModels + ), + catch: (error) => { + // Scalus error messages and evaluation logs, if any, are available to form an exception + const msg: string = error.message + const logs: string[] = error.logs + + return new TransactionBuilder.EvaluationError({ + cause: error, + msg, + [] + }) + } + }) + + + // Transform Scalus redeemers to Evolution format + const evalRedeemers: EvalRedeemer.EvalRedeemer[] = redeemers.map((r: any) => { + const tagMap: Record = { + "Spend": "spend", + "Mint": "mint", + "Cert": "publish", + "Reward": "withdraw", + "Voting": "vote", + "Proposing": "propose" + } + + return { + redeemer_tag: tagMap[r.tag] || "spend", + redeemer_index: r.index, + ex_units: { + mem: Number(r.budget.memory), + steps: Number(r.budget.steps) + } + } + }) + + return evalRedeemers + }) + } +} diff --git a/packages/scalus-uplc/src/index.browser.ts b/packages/scalus-uplc/src/index.browser.ts new file mode 100644 index 00000000..fa6a2aea --- /dev/null +++ b/packages/scalus-uplc/src/index.browser.ts @@ -0,0 +1,16 @@ +/** + * Browser entry point - not implemented in rough draft + * + * @packageDocumentation + */ + +import type * as TransactionBuilder from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder" + +/** + * Create a Scalus UPLC evaluator instance for browser environments. + * + * @throws Error - Browser support not yet implemented + */ +export function createScalusEvaluator(): TransactionBuilder.Evaluator { + throw new Error("Browser support not yet implemented for Scalus UPLC evaluator") +} diff --git a/packages/scalus-uplc/src/index.node.ts b/packages/scalus-uplc/src/index.node.ts new file mode 100644 index 00000000..807e35da --- /dev/null +++ b/packages/scalus-uplc/src/index.node.ts @@ -0,0 +1,16 @@ +import { makeEvaluator } from "./Evaluator.js" + +/** + * Create a Scalus UPLC evaluator instance. + * + * @returns A TransactionBuilder.Evaluator that uses Scalus for script evaluation + * + * @example + * ```typescript + * import { createScalusEvaluator } from "@evolution-sdk/scalus-uplc" + * + * const evaluator = createScalusEvaluator() + * const redeemers = await Effect.runPromise(evaluator.evaluate(tx, utxos, context)) + * ``` + */ +export const createScalusEvaluator = makeEvaluator() diff --git a/packages/scalus-uplc/tsconfig.build.json b/packages/scalus-uplc/tsconfig.build.json new file mode 100644 index 00000000..02c325c6 --- /dev/null +++ b/packages/scalus-uplc/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "dist", + "types": ["node"], + "stripInternal": true + } +} diff --git a/packages/scalus-uplc/tsconfig.json b/packages/scalus-uplc/tsconfig.json new file mode 100644 index 00000000..6e773dfe --- /dev/null +++ b/packages/scalus-uplc/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "files": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/scalus-uplc/tsconfig.src.json b/packages/scalus-uplc/tsconfig.src.json new file mode 100644 index 00000000..0f916616 --- /dev/null +++ b/packages/scalus-uplc/tsconfig.src.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "outDir": ".tsbuildinfo/src", + "rootDir": "src" + } +} diff --git a/packages/scalus-uplc/tsconfig.test.json b/packages/scalus-uplc/tsconfig.test.json new file mode 100644 index 00000000..63bfa3bd --- /dev/null +++ b/packages/scalus-uplc/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["test/**/*", "**/*.test.ts", "**/*.spec.ts"], + "references": [{ "path": "tsconfig.src.json" }], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "outDir": ".tsbuildinfo/test", + "noEmit": true, + "baseUrl": ".", + "paths": { + "@evolution-sdk/scalus-uplc": ["src/index.node.ts"], + "@evolution-sdk/scalus-uplc/*": ["src/*/index.ts", "src/*.ts"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6abdc31..5807515b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,28 @@ importers: specifier: ^5.9.2 version: 5.9.2 + packages/scalus-uplc: + dependencies: + '@evolution-sdk/evolution': + specifier: workspace:* + version: link:../evolution + effect: + specifier: ^3.19.3 + version: 3.19.3 + scalus: + specifier: ^0.14.0 + version: 0.14.0 + devDependencies: + '@effect/vitest': + specifier: ^0.19.3 + version: 0.19.10(effect@3.19.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1)) + typescript: + specifier: ^5.9.2 + version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1) + packages: '@algolia/abtesting@1.9.0': @@ -615,6 +637,12 @@ packages: '@effect/platform': ^0.90.4 effect: ^3.17.7 + '@effect/vitest@0.19.10': + resolution: {integrity: sha512-eV+Vu/3mqqpzAzo2Cb5/ZpBnUwIeDMOy4vAGCUv5bRzKq4HiTK23yYCEB9g5G+ZkPyQ63oOSO/7GHxS5f1sNtg==} + peerDependencies: + effect: ^3.13.12 + vitest: ^3.0.0 + '@effect/vitest@0.25.1': resolution: {integrity: sha512-OMYvOU8iGed8GZXxgVBXlYtjG+jwWj5cJxFk0hOHOfTbCHXtdCMEWlXNba5zxbE7dBnW4srbnSYrP/NGGTC3qQ==} peerDependencies: @@ -5343,6 +5371,9 @@ packages: engines: {node: '>=18'} hasBin: true + scalus@0.14.0: + resolution: {integrity: sha512-Gv+t24uJo8rt08zUYO7g6EzXByht0wuAmUwhd2gLhzbopalmFm0EnOsfa363ta84YKO9+V1ouTVD5Y6NNRV8jQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5658,9 +5689,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.0.1: - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} - tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -6275,7 +6303,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.5.0 - tinyexec: 1.0.1 + tinyexec: 1.0.2 '@antfu/utils@9.3.0': {} @@ -6390,7 +6418,7 @@ snapshots: '@babel/parser': 7.28.3 '@babel/template': 7.27.2 '@babel/types': 7.28.2 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6690,6 +6718,11 @@ snapshots: effect: 3.19.3 uuid: 11.1.0 + '@effect/vitest@0.19.10(effect@3.19.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1))': + dependencies: + effect: 3.19.3 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1) + '@effect/vitest@0.25.1(effect@3.19.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1))': dependencies: effect: 3.19.3 @@ -6816,7 +6849,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.1 + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -9955,7 +9988,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10163,14 +10196,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10468,7 +10501,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.30 - debug: 4.4.1 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -11499,7 +11532,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -11691,7 +11724,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -12060,6 +12093,8 @@ snapshots: commander: 12.1.0 enhanced-resolve: 5.18.3 + scalus@0.14.0: {} + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -12204,7 +12239,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 socks: 2.8.7 transitivePeerDependencies: - supports-color @@ -12421,8 +12456,6 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.0.1: {} - tinyexec@1.0.2: {} tinyglobby@0.2.14: @@ -12755,7 +12788,7 @@ snapshots: vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1) From d508c1809f696ab94fe01a5d7d68c103f8a5c4da Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 29 Dec 2025 15:48:52 +0100 Subject: [PATCH 2/5] clean up --- packages/scalus-uplc/src/Evaluator.ts | 34 --------------------------- 1 file changed, 34 deletions(-) diff --git a/packages/scalus-uplc/src/Evaluator.ts b/packages/scalus-uplc/src/Evaluator.ts index 9556e5cc..f0dc2d41 100644 --- a/packages/scalus-uplc/src/Evaluator.ts +++ b/packages/scalus-uplc/src/Evaluator.ts @@ -1,43 +1,9 @@ -import * as Bytes from "@evolution-sdk/evolution/core/Bytes" import * as Transaction from "@evolution-sdk/evolution/core/Transaction" import type * as UTxO from "@evolution-sdk/evolution/core/UTxO" import * as TransactionBuilder from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder" import type * as EvalRedeemer from "@evolution-sdk/evolution/sdk/EvalRedeemer" import { Effect } from "effect" import * as Scalus from "scalus" - -/** - * Parse Scalus error string into ScriptFailure array. - */ -function parseScalusError(error: unknown): Array { - const failures: Array = [] - - // Check if it's a PlutusScriptEvaluationException with logs - if (error && typeof error === "object" && "logs" in error) { - const logs = (error as any).logs || [] - const errorMessage = error instanceof Error ? error.message : String(error) - - // TODO: Parse error message for structured failures - // For rough draft: simple fallback - failures.push({ - purpose: "unknown", - index: 0, - validationError: errorMessage, - traces: logs - }) - } else { - const errorMessage = error instanceof Error ? error.message : String(error) - failures.push({ - purpose: "unknown", - index: 0, - validationError: errorMessage, - traces: [] - }) - } - - return failures -} - /** * Build CBOR-encoded UTxO map for Scalus. * Scalus expects: Map[TransactionInput, TransactionOutput] From 74eb87aa0819feb541f39fb9c2109a3847c250bc Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 29 Dec 2025 15:49:17 +0100 Subject: [PATCH 3/5] clean up --- packages/scalus-uplc/src/Evaluator.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/scalus-uplc/src/Evaluator.ts b/packages/scalus-uplc/src/Evaluator.ts index f0dc2d41..7f2f81f7 100644 --- a/packages/scalus-uplc/src/Evaluator.ts +++ b/packages/scalus-uplc/src/Evaluator.ts @@ -4,10 +4,7 @@ import * as TransactionBuilder from "@evolution-sdk/evolution/sdk/builders/Trans import type * as EvalRedeemer from "@evolution-sdk/evolution/sdk/EvalRedeemer" import { Effect } from "effect" import * as Scalus from "scalus" -/** - * Build CBOR-encoded UTxO map for Scalus. - * Scalus expects: Map[TransactionInput, TransactionOutput] - */ + function buildUtxoMapCBOR(utxos: ReadonlyArray): Uint8Array { // lucid specific way to encode the utxos as CBOR -- a util most likely exists return new Uint8Array([0xa0]) @@ -20,7 +17,7 @@ function decodeCostModels(context: TransactionBuilder.EvaluationContext): Array< const plutusV1 = [] const plutusV2 = [] const plutusV3 = [] - return [plutusV1, plutusV2, plutusV3] // PlutusV1, V2, V3 + return [plutusV1, plutusV2, plutusV3] } /** From b81ced8d719cb26a2c565ad829b24b5db1a4cc7d Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 29 Dec 2025 15:54:33 +0100 Subject: [PATCH 4/5] clean up & clarify --- packages/scalus-uplc/src/Evaluator.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/scalus-uplc/src/Evaluator.ts b/packages/scalus-uplc/src/Evaluator.ts index 7f2f81f7..b9a6cde6 100644 --- a/packages/scalus-uplc/src/Evaluator.ts +++ b/packages/scalus-uplc/src/Evaluator.ts @@ -10,19 +10,14 @@ function buildUtxoMapCBOR(utxos: ReadonlyArray): Uint8Array { return new Uint8Array([0xa0]) } -/** - * Decode CBOR cost models to array format. - */ function decodeCostModels(context: TransactionBuilder.EvaluationContext): Array> { + // Scalus expects a flattened representation of the cost models const plutusV1 = [] const plutusV2 = [] const plutusV3 = [] return [plutusV1, plutusV2, plutusV3] } -/** - * Create Scalus evaluator - */ export function makeEvaluator(): TransactionBuilder.Evaluator { return { evaluate: ( From 13d7d43060149ec4df3c3b0735c2b5dac4a21bb3 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Mon, 29 Dec 2025 17:54:19 -0700 Subject: [PATCH 5/5] refactor: use core costmodels instead of uint8array; attempt to use scalus evaluator --- packages/aiken-uplc/src/Evaluator.ts | 10 +- packages/evolution-devnet/package.json | 1 + .../test/TxBuilder.Scripts.test.ts | 75 ++++++++- .../src/sdk/builders/TransactionBuilder.ts | 5 +- .../src/sdk/builders/phases/Evaluation.ts | 28 ++-- packages/scalus-uplc/src/Evaluator.ts | 146 +++++++++++++----- pnpm-lock.yaml | 3 + 7 files changed, 200 insertions(+), 68 deletions(-) diff --git a/packages/aiken-uplc/src/Evaluator.ts b/packages/aiken-uplc/src/Evaluator.ts index 76950b77..87d0cea5 100644 --- a/packages/aiken-uplc/src/Evaluator.ts +++ b/packages/aiken-uplc/src/Evaluator.ts @@ -6,6 +6,7 @@ import * as Bytes from "@evolution-sdk/evolution/core/Bytes" import * as CBOR from "@evolution-sdk/evolution/core/CBOR" +import * as CostModel from "@evolution-sdk/evolution/core/CostModel" import * as Redeemer from "@evolution-sdk/evolution/core/Redeemer" import * as Script from "@evolution-sdk/evolution/core/Script" import * as ScriptRef from "@evolution-sdk/evolution/core/ScriptRef" @@ -154,11 +155,14 @@ export function makeEvaluator(wasmModule: WasmLoader.WasmModule): TransactionBui const { slotLength, zeroSlot, zeroTime } = context.slotConfig + // Encode cost models to CBOR bytes for WASM evaluator + const costModelsCBOR = CostModel.toCBOR(context.costModels) + yield* Effect.logDebug( `[Aiken UPLC] Slot config - zeroTime: ${zeroTime}, zeroSlot: ${zeroSlot}, slotLength: ${slotLength}` ) - yield* Effect.logDebug(`[Aiken UPLC] Cost models CBOR length: ${context.costModels.length} bytes`) - yield* Effect.logDebug(`[Aiken UPLC] Cost models hex: ${Bytes.toHex(context.costModels)}`) + yield* Effect.logDebug(`[Aiken UPLC] Cost models CBOR length: ${costModelsCBOR.length} bytes`) + yield* Effect.logDebug(`[Aiken UPLC] Cost models hex: ${Bytes.toHex(costModelsCBOR)}`) yield* Effect.logDebug( `[Aiken UPLC] Max execution - steps: ${context.maxTxExSteps}, mem: ${context.maxTxExMem}` ) @@ -173,7 +177,7 @@ export function makeEvaluator(wasmModule: WasmLoader.WasmModule): TransactionBui txBytes, utxosX, utxosY, - context.costModels, + costModelsCBOR, context.maxTxExSteps, context.maxTxExMem, BigInt(zeroTime), diff --git a/packages/evolution-devnet/package.json b/packages/evolution-devnet/package.json index b3b9596e..4712a434 100644 --- a/packages/evolution-devnet/package.json +++ b/packages/evolution-devnet/package.json @@ -49,6 +49,7 @@ "peerDependencies": { "@evolution-sdk/aiken-uplc": "workspace:*", "@evolution-sdk/evolution": "workspace:*", + "@evolution-sdk/scalus-uplc": "workspace:*", "@effect/platform": "^0.90.10", "@effect/platform-node": "^0.96.1", "effect": "^3.19.3" diff --git a/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts b/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts index 24d25364..ac9de2e3 100644 --- a/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts @@ -9,6 +9,7 @@ import * as ScriptHash from "@evolution-sdk/evolution/core/ScriptHash" import type { TxBuilderConfig } from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder" import { makeTxBuilder } from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder" import { KupmiosProvider } from "@evolution-sdk/evolution/sdk/provider/Kupmios" +import { createScalusEvaluator } from "@evolution-sdk/scalus-uplc" import { Schema } from "effect" import * as Cluster from "../src/Cluster.js" @@ -264,11 +265,75 @@ describe("TxBuilder Script Handling", () => { const redeemer = tx.witnessSet.redeemers![0] expect(redeemer.tag).toBe("spend") - expect(redeemer.exUnits.mem).toBeGreaterThan(0n) // mem > 0 - expect(redeemer.exUnits.steps).toBeGreaterThan(0n) // steps > 0 - - // eslint-disable-next-line no-console - console.log(`✓ Aiken evaluator: mem=${redeemer.exUnits.mem}, steps=${redeemer.exUnits.steps}`) + expect(redeemer.exUnits.mem).toBe(1100n) + expect(redeemer.exUnits.steps).toBe(160100n) + }) + + it.fails("should build transaction with Scalus evaluator", async () => { + const alwaysSucceedsScript = makePlutusV2Script(ALWAYS_SUCCEED_SCRIPT_CBOR) + const scriptAddress = scriptToAddress(ALWAYS_SUCCEED_SCRIPT_CBOR) + + // Create script UTxO with inline datum + const ownerPubKeyHash = "00000000000000000000000000000000000000000000000000000000" + const datum = Data.toCBORHex(Data.constr(0n, [Data.bytearray(ownerPubKeyHash)])) + + const scriptUtxo = createCoreTestUtxo({ + transactionId: "a".repeat(64), + index: 0, + address: scriptAddress, + lovelace: 5_000_000n, + datumOption: { type: "inlineDatum", inline: datum } + }) + + // Create funding UTxO + const fundingUtxo = createCoreTestUtxo({ + transactionId: "b".repeat(64), + index: 0, + address: CHANGE_ADDRESS, + lovelace: 10_000_000n + }) + + // Create redeemer + const redeemerData = Data.constr(0n, [Data.bytearray("48656c6c6f2c20576f726c6421")]) + + const builder = makeTxBuilder(baseConfig) + .collectFrom({ + inputs: [scriptUtxo], + redeemer: redeemerData + }) + .attachScript({ script: alwaysSucceedsScript }) + .payToAddress({ + address: CoreAddress.fromBech32(RECEIVER_ADDRESS), + assets: CoreAssets.fromLovelace(2_000_000n) + }) + + // Build with Scalus evaluator and debug enabled + const signBuilder = await builder.build({ + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: [fundingUtxo], + protocolParameters: PROTOCOL_PARAMS, + evaluator: createScalusEvaluator, + debug: true // Enable debug logging + }) + + const tx = await signBuilder.toTransaction() + + // Verify transaction structure + expect(tx.body.inputs.length).toBe(1) + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(2) // Payment + change + + // Verify script witnesses + expect(tx.witnessSet.plutusV2Scripts).toBeDefined() + expect(tx.witnessSet.plutusV2Scripts!.length).toBe(1) + + // Verify redeemers with evaluated exUnits + expect(tx.witnessSet.redeemers).toBeDefined() + expect(tx.witnessSet.redeemers!.length).toBe(1) + + const redeemer = tx.witnessSet.redeemers![0] + expect(redeemer.tag).toBe("spend") + expect(redeemer.exUnits.mem).toBe(1100n) + expect(redeemer.exUnits.steps).toBe(160100n) }) it("should handle collateral inputs with multiassets and return excess to user as collateral return", async () => { diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 7672d18b..f92c02ba 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -33,6 +33,7 @@ import * as CoreAssets from "../../core/Assets/index.js" import type * as AuxiliaryData from "../../core/AuxiliaryData.js" import type * as Certificate from "../../core/Certificate.js" import type * as Coin from "../../core/Coin.js" +import type * as CostModel from "../../core/CostModel.js" import type * as PlutusData from "../../core/Data.js" import type * as KeyHash from "../../core/KeyHash.js" import type * as Mint from "../../core/Mint.js" @@ -687,7 +688,7 @@ export interface ChainResult { */ export interface EvaluationContext { /** Cost models for script evaluation */ - readonly costModels: Uint8Array + readonly costModels: CostModel.CostModels /** Maximum execution steps allowed */ readonly maxTxExSteps: bigint /** Maximum execution memory allowed */ @@ -763,7 +764,7 @@ export interface ScriptFailure { * @category errors */ export class EvaluationError extends Data.TaggedError("EvaluationError")<{ - readonly cause: unknown + readonly cause?: unknown readonly message?: string /** Parsed script failures with labels */ readonly failures?: ReadonlyArray diff --git a/packages/evolution/src/sdk/builders/phases/Evaluation.ts b/packages/evolution/src/sdk/builders/phases/Evaluation.ts index 20dd0a0f..725bcd33 100644 --- a/packages/evolution/src/sdk/builders/phases/Evaluation.ts +++ b/packages/evolution/src/sdk/builders/phases/Evaluation.ts @@ -34,14 +34,14 @@ import { assembleTransaction, buildTransactionInputs } from "../TxBuilderImpl.js import type { PhaseResult } from "./Phases.js" /** - * Convert ProtocolParameters cost models to CBOR bytes for evaluation. + * Convert ProtocolParameters cost models to CostModels core type for evaluation. * * Takes the cost models from protocol parameters (Record format) - * and converts them to the CBOR-encoded format expected by UPLC evaluators. + * and converts them to the CostModels core type. */ -const costModelsToCBOR = ( +const buildCostModels = ( protocolParams: ProtocolParametersModule.ProtocolParameters -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // Convert Record format to bigint arrays const plutusV1Costs = Object.values(protocolParams.costModels.PlutusV1).map((v) => BigInt(v)) @@ -51,22 +51,12 @@ const costModelsToCBOR = ( .filter((v) => v <= INT64_MAX) const plutusV3Costs = Object.values(protocolParams.costModels.PlutusV3).map((v) => BigInt(v)) - // Create CostModels instance - const costModels = new CostModel.CostModels({ + // Create and return CostModels instance + return new CostModel.CostModels({ PlutusV1: new CostModel.CostModel({ costs: plutusV1Costs }), PlutusV2: new CostModel.CostModel({ costs: plutusV2Costs }), PlutusV3: new CostModel.CostModel({ costs: plutusV3Costs }) }) - - // Encode to CBOR bytes - return yield* Effect.try({ - try: () => CostModel.toCBOR(costModels), - catch: (error) => - new TransactionBuilderError({ - message: "Failed to encode cost models to CBOR", - cause: error - }) - }) }) /** @@ -457,8 +447,8 @@ export const executeEvaluation = (): Effect.Effect< } // Step 6: Prepare evaluation context - // Encode cost models from full protocol parameters - const costModelsCBOR = yield* costModelsToCBOR(fullProtocolParams) + // Build cost models from full protocol parameters + const costModels = yield* buildCostModels(fullProtocolParams) // Get slot configuration from BuildOptions (resolved from network or explicit override) const slotConfig = buildOptions.slotConfig ?? { @@ -468,7 +458,7 @@ export const executeEvaluation = (): Effect.Effect< } const evaluationContext: EvaluationContext = { - costModels: costModelsCBOR, + costModels, maxTxExSteps: fullProtocolParams.maxTxExSteps, maxTxExMem: fullProtocolParams.maxTxExMem, slotConfig diff --git a/packages/scalus-uplc/src/Evaluator.ts b/packages/scalus-uplc/src/Evaluator.ts index b9a6cde6..917f0bc3 100644 --- a/packages/scalus-uplc/src/Evaluator.ts +++ b/packages/scalus-uplc/src/Evaluator.ts @@ -1,20 +1,54 @@ +import * as Bytes from "@evolution-sdk/evolution/core/Bytes" +import * as CBOR from "@evolution-sdk/evolution/core/CBOR" +import type * as CostModel from "@evolution-sdk/evolution/core/CostModel" +import * as Script from "@evolution-sdk/evolution/core/Script" +import * as ScriptRef from "@evolution-sdk/evolution/core/ScriptRef" import * as Transaction from "@evolution-sdk/evolution/core/Transaction" +import * as TransactionInput from "@evolution-sdk/evolution/core/TransactionInput" +import * as TxOut from "@evolution-sdk/evolution/core/TxOut" import type * as UTxO from "@evolution-sdk/evolution/core/UTxO" import * as TransactionBuilder from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder" import type * as EvalRedeemer from "@evolution-sdk/evolution/sdk/EvalRedeemer" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Scalus from "scalus" +/** + * Build CBOR-encoded map of TransactionInput → TransactionOutput from UTxOs. + * + * Uses FromCDDL schemas to get CBOR values directly, avoiding wasteful + * bytes → CBOR → bytes roundtrip encoding. + */ function buildUtxoMapCBOR(utxos: ReadonlyArray): Uint8Array { - // lucid specific way to encode the utxos as CBOR -- a util most likely exists - return new Uint8Array([0xa0]) + 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(context: TransactionBuilder.EvaluationContext): Array> { - // Scalus expects a flattened representation of the cost models - const plutusV1 = [] - const plutusV2 = [] - const plutusV3 = [] +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) => Number(c)) + const plutusV2 = costModels.PlutusV2.costs.map((c) => Number(c)) + const plutusV3 = costModels.PlutusV3.costs.map((c) => Number(c)) return [plutusV1, plutusV2, plutusV3] } @@ -26,64 +60,98 @@ export function makeEvaluator(): TransactionBuilder.Evaluator { 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 - const costModels = decodeCostModels(context) + yield* Effect.logDebug( + `[Scalus UPLC] Slot config - zeroTime: ${zeroTime}, zeroSlot: ${zeroSlot}, slotLength: ${slotLength}` + ) + + const costModels = 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 Scalus.SlotConfig( - Number(zeroTime), - Number(zeroSlot), - slotLength - ) + const slotConfig = new Scalus.SlotConfig(Number(zeroTime), Number(zeroSlot), slotLength) + yield* Effect.logDebug("[Scalus UPLC] Calling evalPlutusScripts...") const redeemers = yield* Effect.try({ try: () => - Scalus.Scalus.evalPlutusScripts( - Array.from(txBytes), - Array.from(utxosBytes), - slotConfig, - costModels - ), + Scalus.Scalus.evalPlutusScripts(Array.from(txBytes), Array.from(utxosBytes), slotConfig, costModels), catch: (error) => { // Scalus error messages and evaluation logs, if any, are available to form an exception - const msg: string = error.message - const logs: string[] = error.logs + const errorObj = error as any + const msg: string = errorObj?.message ?? "Unknown evaluation error" return new TransactionBuilder.EvaluationError({ cause: error, - msg, - [] + 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 exUnits = { + mem: Number(r.budget.memory), + steps: Number(r.budget.steps) + } + + // Check if execution units are zero (indicates evaluation failure) + if (exUnits.mem === 0 && exUnits.steps === 0) { + return yield* Effect.fail( + new TransactionBuilder.EvaluationError({ + message: `Scalus evaluation returned zero execution units for redeemer ${r.tag}:${r.index}`, + failures: [] + }) + ) + } - // Transform Scalus redeemers to Evolution format - const evalRedeemers: EvalRedeemer.EvalRedeemer[] = redeemers.map((r: any) => { const tagMap: Record = { - "Spend": "spend", - "Mint": "mint", - "Cert": "publish", - "Reward": "withdraw", - "Voting": "vote", - "Proposing": "propose" + Spend: "spend", + Mint: "mint", + Cert: "publish", + Reward: "withdraw", + Voting: "vote", + Proposing: "propose" } - return { + evalRedeemers.push({ redeemer_tag: tagMap[r.tag] || "spend", redeemer_index: r.index, - ex_units: { - mem: Number(r.budget.memory), - steps: Number(r.budget.steps) - } - } - }) + ex_units: exUnits + }) + } return evalRedeemers }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5807515b..16dc3818 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@evolution-sdk/evolution': specifier: workspace:* version: link:../evolution + '@evolution-sdk/scalus-uplc': + specifier: workspace:* + version: link:../scalus-uplc '@noble/hashes': specifier: ^1.8.0 version: 1.8.0