From a987c990c5951bbcc2617cdd14292388e656ceb7 Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 22 Dec 2025 19:05:53 +0100 Subject: [PATCH 1/6] feat: offline evaluator --- buildSrc/src/main/kotlin/Dependencies.kt | 6 + buildSrc/src/main/kotlin/Versions.kt | 1 + newm-tx-builder/build.gradle.kts | 2 + .../io/newm/txbuilder/ScalusEvaluator.kt | 140 ++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 5320feea2..a6f6c85eb 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -277,6 +277,12 @@ object Dependencies { const val KOGMIOS = "io.newm:kogmios:$VERSION" } + object Scalus { + private const val VERSION = Versions.SCALUS + + const val CARDANO_LEDGER = "org.scalus:scalus-cardano-ledger_3:$VERSION" + } + object Grpc { private const val VERSION = Versions.GRPC diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 4f5068edc..9a47eb1e8 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -42,6 +42,7 @@ object Versions { const val QR_CODE_KOTLIN = "4.5.0" const val QUARTZ = "2.5.0" const val SCALA_JAVA8_COMPAT = "1.0.2" + const val SCALUS = "0.14.0" const val SENTRY = "8.24.0" const val SHADOW_PLUGIN = "9.2.1" const val SPRING_SECURITY = "6.5.6" diff --git a/newm-tx-builder/build.gradle.kts b/newm-tx-builder/build.gradle.kts index 048699750..52b41215b 100644 --- a/newm-tx-builder/build.gradle.kts +++ b/newm-tx-builder/build.gradle.kts @@ -34,6 +34,8 @@ dependencies { implementation(Dependencies.ApacheCommonsNumbers.FRACTION) + implementation(Dependencies.Scalus.CARDANO_LEDGER) + testImplementation(platform(Dependencies.JUnit.BOM)) testImplementation(Dependencies.JUnit.JUPITER_API) testImplementation(Dependencies.JUnit.JUPITER_PARAMS) diff --git a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt new file mode 100644 index 000000000..790b8e163 --- /dev/null +++ b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt @@ -0,0 +1,140 @@ +package io.newm.txbuilder + +import io.newm.chain.grpc.Utxo +import io.newm.chain.util.config.Config +import io.newm.kogmios.protocols.model.ExecutionUnits +import io.newm.kogmios.protocols.model.Validator +import io.newm.kogmios.protocols.model.result.EvaluateTx +import io.newm.kogmios.protocols.model.result.EvaluateTxResult +import scalus.cardano.ledger.Redeemer +import scalus.cardano.ledger.RedeemerTag +import java.math.BigInteger +import scala.Option +import scala.Tuple2 +import scalus.builtin.ByteString +import scalus.cardano.address.Address +import scalus.cardano.ledger.CardanoInfo +import scalus.cardano.ledger.Coin +import scalus.cardano.ledger.EvaluatorMode +import scalus.builtin.Data +import scalus.cardano.ledger.DatumOption +import scalus.cardano.ledger.MultiAsset +import scalus.cardano.ledger.PlutusScriptEvaluator +import scalus.cardano.ledger.ProtocolVersion +import scalus.cardano.ledger.Transaction +import scalus.cardano.ledger.TransactionInput +import scalus.cardano.ledger.TransactionOutput +import scalus.cardano.ledger.Value + +import scalus.cardano.ledger.`Hashes$package$` + +/** + * A script evaluator that uses Scalus' local CEK machine implementation, allowing to get tx redeemers without + * using the network. + */ +object ScalusEvaluator { + /** + * Evaluates a transaction's Plutus scripts and returns execution units. + * + * @param cborBytes The transaction CBOR bytes + * @param utxos All UTxOs needed for evaluation (source + reference inputs) + * @param config The configuration object containing network information + */ + fun evaluateTx( + cborBytes: ByteArray, + utxos: Set, + config: Config, + ): EvaluateTxResult { + val cardanoInfo = + if (config.isMainnet) { + CardanoInfo.mainnet() + } else { + CardanoInfo.preprod() + } + + val evaluator = PlutusScriptEvaluator.apply(cardanoInfo, EvaluatorMode.EvaluateAndComputeCost) + val tx = Transaction.fromCbor(cborBytes, ProtocolVersion.conwayPV()) + val scalusUtxos = convertToScalusUtxos(utxos) + + val evaluatedRedeemers = evaluator.evalPlutusScripts(tx, scalusUtxos) + val result = convertToEvaluateTxResult(evaluatedRedeemers) + result + } + + private fun convertToEvaluateTxResult(evaluatedRedeemers: scala.collection.immutable.Seq): EvaluateTxResult { + val result = EvaluateTxResult() + val iterator = evaluatedRedeemers.iterator() + while (iterator.hasNext()) { + val redeemer = iterator.next() + + val tag = redeemer.tag() + val purpose = + when (tag) { + RedeemerTag.valueOf("Spend") -> "spend" + RedeemerTag.valueOf("Mint") -> "mint" + RedeemerTag.valueOf("Cert") -> "certificate" + RedeemerTag.valueOf("Reward") -> "withdrawal" + RedeemerTag.valueOf("Voting") -> "vote" + RedeemerTag.valueOf("Proposing") -> "propose" + else -> throw IllegalStateException("Unknown redeemer tag: $tag") + } + + val validator = Validator(redeemer.index().toInt(), purpose) + val executionUnits = + ExecutionUnits( + memory = BigInteger.valueOf(redeemer.exUnits().memory()), + cpu = BigInteger.valueOf(redeemer.exUnits().steps()) + ) + + result.add(EvaluateTx(validator, executionUnits)) + } + + return result + } + + private fun convertToScalusUtxos(utxos: Set): scala.collection.immutable.Map { + val entries = mutableListOf>() + + // Map the utxo to Scalus ledger types. + utxos.forEach { utxo -> + val txHash = `Hashes$package$`.TransactionHash.fromHex(utxo.hash) + val txInput = TransactionInput.apply(txHash, utxo.ix.toInt()) + + val address = Address.fromBech32(utxo.address) + + val lovelace = Coin.apply(utxo.lovelace.toLong()) + val value = Value.apply(lovelace, MultiAsset.empty()) + + val datumOption: Option = + when { + utxo.hasDatum() && utxo.isInlineDatum -> { + // Inline datum - parse from CBOR hex + val datumBytes = hexToByteArray(utxo.datum.cborHex) + val datumData = Data.fromCbor(datumBytes) + Option.apply(DatumOption.Inline.apply(datumData)) + } + + !utxo.datumHash.isNullOrEmpty() -> { + val datumHash = `Hashes$package$`.DataHash.fromHex(utxo.datumHash) + Option.apply(DatumOption.Hash.apply(datumHash)) + } + + else -> { + Option.empty() + } + } + + val txOutput = TransactionOutput.apply(address, value, datumOption, Option.empty()) + + entries.add(Tuple2(txInput, txOutput)) + } + + val scalaBuffer = scala.jdk.javaapi.CollectionConverters + .asScala(entries.toList()) + val scalaSeq = scalaBuffer.toSeq() + return scala.collection.immutable.Map + .from(scalaSeq) + } + + private fun hexToByteArray(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} From 3b85e5ed3df71b44f5ad50213aa314e45827e2ee Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 22 Dec 2025 19:19:23 +0100 Subject: [PATCH 2/6] bump scalus --- buildSrc/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 9a47eb1e8..786590cec 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -42,7 +42,7 @@ object Versions { const val QR_CODE_KOTLIN = "4.5.0" const val QUARTZ = "2.5.0" const val SCALA_JAVA8_COMPAT = "1.0.2" - const val SCALUS = "0.14.0" + const val SCALUS = "0.14.1" const val SENTRY = "8.24.0" const val SHADOW_PLUGIN = "9.2.1" const val SPRING_SECURITY = "6.5.6" From ca0630849c136dcb7476dd186777a00fe45117f1 Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 22 Dec 2025 20:24:10 +0100 Subject: [PATCH 3/6] use the correct hex util --- .../src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt index 790b8e163..5abd5601a 100644 --- a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt +++ b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt @@ -25,6 +25,7 @@ import scalus.cardano.ledger.Transaction import scalus.cardano.ledger.TransactionInput import scalus.cardano.ledger.TransactionOutput import scalus.cardano.ledger.Value +import io.newm.chain.util.hexToByteArray import scalus.cardano.ledger.`Hashes$package$` @@ -57,8 +58,7 @@ object ScalusEvaluator { val scalusUtxos = convertToScalusUtxos(utxos) val evaluatedRedeemers = evaluator.evalPlutusScripts(tx, scalusUtxos) - val result = convertToEvaluateTxResult(evaluatedRedeemers) - result + return convertToEvaluateTxResult(evaluatedRedeemers) } private fun convertToEvaluateTxResult(evaluatedRedeemers: scala.collection.immutable.Seq): EvaluateTxResult { @@ -109,7 +109,7 @@ object ScalusEvaluator { when { utxo.hasDatum() && utxo.isInlineDatum -> { // Inline datum - parse from CBOR hex - val datumBytes = hexToByteArray(utxo.datum.cborHex) + val datumBytes = utxo.datum.cborHex.hexToByteArray() val datumData = Data.fromCbor(datumBytes) Option.apply(DatumOption.Inline.apply(datumData)) } @@ -135,6 +135,4 @@ object ScalusEvaluator { return scala.collection.immutable.Map .from(scalaSeq) } - - private fun hexToByteArray(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } From 728c565c897ff02a2ba13010e7285b7d96320312 Mon Sep 17 00:00:00 2001 From: otto Date: Tue, 23 Dec 2025 00:43:54 +0100 Subject: [PATCH 4/6] cost models --- .../io/newm/txbuilder/ScalusEvaluator.kt | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt index 5abd5601a..f021ab446 100644 --- a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt +++ b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt @@ -6,8 +6,10 @@ import io.newm.kogmios.protocols.model.ExecutionUnits import io.newm.kogmios.protocols.model.Validator import io.newm.kogmios.protocols.model.result.EvaluateTx import io.newm.kogmios.protocols.model.result.EvaluateTxResult +import io.newm.kogmios.protocols.model.result.ProtocolParametersResult import scalus.cardano.ledger.Redeemer import scalus.cardano.ledger.RedeemerTag +import scalus.cardano.ledger.SlotConfig import java.math.BigInteger import scala.Option import scala.Tuple2 @@ -16,6 +18,8 @@ import scalus.cardano.address.Address import scalus.cardano.ledger.CardanoInfo import scalus.cardano.ledger.Coin import scalus.cardano.ledger.EvaluatorMode +import scalus.cardano.ledger.ExUnits +import scalus.cardano.ledger.MajorProtocolVersion import scalus.builtin.Data import scalus.cardano.ledger.DatumOption import scalus.cardano.ledger.MultiAsset @@ -39,21 +43,33 @@ object ScalusEvaluator { * * @param cborBytes The transaction CBOR bytes * @param utxos All UTxOs needed for evaluation (source + reference inputs) - * @param config The configuration object containing network information + * @param protocolParameters Protocol parameters containing cost models and major protocol version + * @param config Configuration object to determine network (mainnet vs testnet) */ fun evaluateTx( cborBytes: ByteArray, utxos: Set, + protocolParameters: ProtocolParametersResult, config: Config, ): EvaluateTxResult { - val cardanoInfo = - if (config.isMainnet) { - CardanoInfo.mainnet() - } else { - CardanoInfo.preprod() - } - - val evaluator = PlutusScriptEvaluator.apply(cardanoInfo, EvaluatorMode.EvaluateAndComputeCost) + val slotConfig = if (config.isMainnet) SlotConfig.Mainnet() else SlotConfig.Preprod() + val costModels = convertCostModels(protocolParameters) + + val initialBudget = ExUnits( + protocolParameters.maxExecutionUnitsPerTransaction.memory.toLong(), + protocolParameters.maxExecutionUnitsPerTransaction.cpu.toLong() + ) + + val evaluator = PlutusScriptEvaluator.apply( + slotConfig, + initialBudget, + MajorProtocolVersion(protocolParameters.version.major), + costModels, + EvaluatorMode.EvaluateAndComputeCost, + false, // debugDumpFilesForTesting + false // logBudgetDifferences + ) + val tx = Transaction.fromCbor(cborBytes, ProtocolVersion.conwayPV()) val scalusUtxos = convertToScalusUtxos(utxos) @@ -61,6 +77,38 @@ object ScalusEvaluator { return convertToEvaluateTxResult(evaluatedRedeemers) } + private fun convertCostModels(protocolParameters: ProtocolParametersResult): scalus.cardano.ledger.CostModels { + val kogmiosCostModels = protocolParameters.plutusCostModels + val tuples = mutableListOf>>() + + // PlutusV1 = language 0 + kogmiosCostModels.plutusV1?.let { v1 -> + tuples.add(scala.Tuple2(0, convertCostModelArray(v1))) + } + + // PlutusV2 = language 1 + kogmiosCostModels.plutusV2?.let { v2 -> + tuples.add(scala.Tuple2(1, convertCostModelArray(v2))) + } + + // PlutusV3 = language 2 + kogmiosCostModels.plutusV3?.let { v3 -> + tuples.add(scala.Tuple2(2, convertCostModelArray(v3))) + } + + // Convert to Scala immutable.Map + val scalaSeq = scala.jdk.javaapi.CollectionConverters.asScala(tuples).toSeq() + @Suppress("UNCHECKED_CAST") + val scalaMap = scala.collection.immutable.Map.from(scalaSeq) as scala.collection.immutable.Map> + + return scalus.cardano.ledger.CostModels(scalaMap) + } + + private fun convertCostModelArray(costModel: List): scala.collection.immutable.IndexedSeq { + val longArray = costModel.map { it.toLong() } + return scala.jdk.javaapi.CollectionConverters.asScala(longArray).toIndexedSeq() + } + private fun convertToEvaluateTxResult(evaluatedRedeemers: scala.collection.immutable.Seq): EvaluateTxResult { val result = EvaluateTxResult() val iterator = evaluatedRedeemers.iterator() From b0e0392a2d8339e5d334b030ae5260697a1e9574 Mon Sep 17 00:00:00 2001 From: otto Date: Wed, 7 Jan 2026 14:14:57 +0100 Subject: [PATCH 5/6] correct enum usage & import cleanup --- .../kotlin/io/newm/txbuilder/ScalusEvaluator.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt index f021ab446..296b25e2e 100644 --- a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt +++ b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt @@ -13,9 +13,7 @@ import scalus.cardano.ledger.SlotConfig import java.math.BigInteger import scala.Option import scala.Tuple2 -import scalus.builtin.ByteString import scalus.cardano.address.Address -import scalus.cardano.ledger.CardanoInfo import scalus.cardano.ledger.Coin import scalus.cardano.ledger.EvaluatorMode import scalus.cardano.ledger.ExUnits @@ -118,13 +116,12 @@ object ScalusEvaluator { val tag = redeemer.tag() val purpose = when (tag) { - RedeemerTag.valueOf("Spend") -> "spend" - RedeemerTag.valueOf("Mint") -> "mint" - RedeemerTag.valueOf("Cert") -> "certificate" - RedeemerTag.valueOf("Reward") -> "withdrawal" - RedeemerTag.valueOf("Voting") -> "vote" - RedeemerTag.valueOf("Proposing") -> "propose" - else -> throw IllegalStateException("Unknown redeemer tag: $tag") + RedeemerTag.Spend -> "spend" + RedeemerTag.Mint -> "mint" + RedeemerTag.Cert -> "certificate" + RedeemerTag.Reward -> "withdrawal" + RedeemerTag.Voting -> "vote" + RedeemerTag.Proposing -> "propose" } val validator = Validator(redeemer.index().toInt(), purpose) From f404a9a4e9c15e99d676325989739df11bfe9d4f Mon Sep 17 00:00:00 2001 From: otto Date: Mon, 12 Jan 2026 09:57:50 +0100 Subject: [PATCH 6/6] access the scala enum fields correctly --- .../io/newm/txbuilder/ScalusEvaluator.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt index 296b25e2e..54989e01a 100644 --- a/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt +++ b/newm-tx-builder/src/main/kotlin/io/newm/txbuilder/ScalusEvaluator.kt @@ -65,7 +65,7 @@ object ScalusEvaluator { costModels, EvaluatorMode.EvaluateAndComputeCost, false, // debugDumpFilesForTesting - false // logBudgetDifferences + false // logBudgetDifferences ) val tx = Transaction.fromCbor(cborBytes, ProtocolVersion.conwayPV()) @@ -95,16 +95,22 @@ object ScalusEvaluator { } // Convert to Scala immutable.Map - val scalaSeq = scala.jdk.javaapi.CollectionConverters.asScala(tuples).toSeq() + val scalaSeq = scala.jdk.javaapi.CollectionConverters + .asScala(tuples) + .toSeq() + @Suppress("UNCHECKED_CAST") - val scalaMap = scala.collection.immutable.Map.from(scalaSeq) as scala.collection.immutable.Map> + val scalaMap = scala.collection.immutable.Map + .from(scalaSeq) as scala.collection.immutable.Map> return scalus.cardano.ledger.CostModels(scalaMap) } private fun convertCostModelArray(costModel: List): scala.collection.immutable.IndexedSeq { val longArray = costModel.map { it.toLong() } - return scala.jdk.javaapi.CollectionConverters.asScala(longArray).toIndexedSeq() + return scala.jdk.javaapi.CollectionConverters + .asScala(longArray) + .toIndexedSeq() } private fun convertToEvaluateTxResult(evaluatedRedeemers: scala.collection.immutable.Seq): EvaluateTxResult { @@ -116,15 +122,16 @@ object ScalusEvaluator { val tag = redeemer.tag() val purpose = when (tag) { - RedeemerTag.Spend -> "spend" - RedeemerTag.Mint -> "mint" - RedeemerTag.Cert -> "certificate" - RedeemerTag.Reward -> "withdrawal" - RedeemerTag.Voting -> "vote" - RedeemerTag.Proposing -> "propose" + scalus.cardano.ledger.`RedeemerTag$`.Spend -> "spend" + scalus.cardano.ledger.`RedeemerTag$`.Mint -> "mint" + scalus.cardano.ledger.`RedeemerTag$`.Cert -> "certificate" + scalus.cardano.ledger.`RedeemerTag$`.Reward -> "withdrawal" + scalus.cardano.ledger.`RedeemerTag$`.Voting -> "vote" + scalus.cardano.ledger.`RedeemerTag$`.Proposing -> "propose" + else -> throw IllegalStateException("Unknown redeemer tag: $tag") } - val validator = Validator(redeemer.index().toInt(), purpose) + val validator = Validator(redeemer.index(), purpose) val executionUnits = ExecutionUnits( memory = BigInteger.valueOf(redeemer.exUnits().memory()),