From 7de7ffb8dad32cda46b28eb969826493b7ba4fc1 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Mon, 16 Feb 2026 17:23:28 +0200 Subject: [PATCH 1/4] feat: diagnostic replay for release scripts in TxBuilder When a script compiled with Options.release (no traces) fails during TxBuilder evaluation, the evaluator automatically recompiles from SIR with error traces and replays to produce diagnostic logs. - Add abstract withErrorTraces to CompiledPlutus base class - Add failedScriptHash to PlutusScriptEvaluationException - Add debugScripts parameter through evaluator and balancing chain - Add CompiledPlutus overloads for spend (4) and mint (3) on TxBuilder - Add references(utxo, compiled) overload for reference script replay - Add withDebugScript for manual debug script registration --- .../txbuilder/DiagnosticReplayTest.scala | 217 ++++++++++++++++++ .../txbuilder/TxBuilderPerformanceTest.scala | 5 +- .../ledger/PlutusScriptEvaluator.scala | 101 ++++++-- .../txbuilder/TransactionBuilder.scala | 34 ++- .../scalus/cardano/txbuilder/TxBuilder.scala | 183 ++++++++++++++- .../src/main/scala/scalus/uplc/Compiled.scala | 10 + scalus-site/content/testing/debugging.mdx | 66 +++++- 7 files changed, 584 insertions(+), 32 deletions(-) create mode 100644 scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala diff --git a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala new file mode 100644 index 000000000..9fe0c77ab --- /dev/null +++ b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala @@ -0,0 +1,217 @@ +package scalus.cardano.txbuilder + +import org.scalatest.funsuite.AnyFunSuite +import scalus.uplc.builtin.ByteString.hex +import scalus.uplc.builtin.Data.toData +import scalus.uplc.builtin.{ByteString, Data} +import scalus.cardano.address.Address +import scalus.cardano.address.Network.Mainnet +import scalus.cardano.ledger.* +import scalus.compiler.Options +import scalus.testing.kit.Party.{Alice, Bob} +import scalus.testing.kit.TestUtil.genAdaOnlyPubKeyUtxo +import scalus.uplc.PlutusV3 + +class DiagnosticReplayTest extends AnyFunSuite { + + given env: CardanoInfo = CardanoInfo.mainnet + + val genesisHash: TransactionHash = + TransactionHash.fromByteString(ByteString.fromHex("0" * 64)) + + def input(index: Int): TransactionInput = Input(genesisHash, index) + def adaOutput(address: Address, ada: Int): TransactionOutput = + Output(address, Value.ada(ada)) + + // A script that always fails with require(false, "expected failure") + val failingScriptWithTraces: PlutusV3[Data => Unit] = { + given Options = Options.default // traces enabled + PlutusV3.compile { (sc: Data) => + scalus.cardano.onchain.plutus.prelude.require(false, "expected failure") + } + } + + val failingScriptRelease: PlutusV3[Data => Unit] = { + given Options = Options.release // no traces + PlutusV3.compile { (sc: Data) => + scalus.cardano.onchain.plutus.prelude.require(false, "expected failure") + } + } + + val alwaysOkScript: PlutusV3[Data => Unit] = { + given Options = Options.release + PlutusV3.compile((sc: Data) => ()) + } + + def createScriptLockedUtxo(script: PlutusScript): Utxo = { + val utxo = genAdaOnlyPubKeyUtxo(Alice).sample.get + val newOutput = Output( + address = Address(Mainnet, Credential.ScriptHash(script.scriptHash)), + value = utxo.output.value, + inlineDatum = 42.toData, + ) + utxo.copy(output = newOutput) + } + + test("script with traces produces logs directly, no replay needed") { + val scriptUtxo = createScriptLockedUtxo(failingScriptWithTraces.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + val ex = intercept[TxBuilderException.BalancingException] { + TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + .spend(scriptUtxo, Data.unit, failingScriptWithTraces) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + } + + assert(ex.isScriptFailure) + val logs = ex.scriptLogs.get + assert(logs.nonEmpty, "Script compiled with traces should produce logs directly") + assert( + logs.exists(_.contains("expected failure")), + s"Logs should contain error message, got: $logs" + ) + } + + test("release script via CompiledPlutus produces diagnostic logs via replay") { + val scriptUtxo = createScriptLockedUtxo(failingScriptRelease.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + val ex = intercept[TxBuilderException.BalancingException] { + TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + .spend(scriptUtxo, Data.unit, failingScriptRelease) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + } + + assert(ex.isScriptFailure) + val logs = ex.scriptLogs.get + assert( + logs.nonEmpty, + "Release script via CompiledPlutus should produce diagnostic logs via replay" + ) + assert( + logs.exists(_.contains("expected failure")), + s"Diagnostic replay logs should contain error message, got: $logs" + ) + } + + test("plain PlutusScript without debug script produces empty logs") { + val scriptUtxo = createScriptLockedUtxo(failingScriptRelease.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + val ex = intercept[TxBuilderException.BalancingException] { + TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + // Use PlutusScript directly - no CompiledPlutus, no debug script + .spend(scriptUtxo, Data.unit, failingScriptRelease.script) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + } + + assert(ex.isScriptFailure) + val logs = ex.scriptLogs.get + assert( + logs.isEmpty, + s"Plain PlutusScript without debug script should produce empty logs, got: $logs" + ) + } + + test("successful script evaluation does not trigger replay") { + val scriptUtxo = createScriptLockedUtxo(alwaysOkScript.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + // This should succeed without any exception + val tx = TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + .spend(scriptUtxo, Data.unit, alwaysOkScript) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + .transaction + + assert(tx.body.value.inputs.toSeq.nonEmpty) + } + + test("references with CompiledPlutus enables diagnostic replay") { + val scriptUtxo = createScriptLockedUtxo(failingScriptRelease.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + // Create a reference UTXO containing the script + val scriptRefUtxo = { + val utxo = genAdaOnlyPubKeyUtxo(Alice).sample.get + val newOutput = Output( + address = Alice.address, + value = utxo.output.value, + datumOption = None, + scriptRef = Some(ScriptRef(failingScriptRelease.script)), + ) + utxo.copy(output = newOutput) + } + + val ex = intercept[TxBuilderException.BalancingException] { + TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + .references(scriptRefUtxo, failingScriptRelease) + .spend(scriptUtxo, Data.unit) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + } + + assert(ex.isScriptFailure) + val logs = ex.scriptLogs.get + assert(logs.nonEmpty, "references(utxo, compiled) should enable diagnostic replay") + assert( + logs.exists(_.contains("expected failure")), + s"Diagnostic replay logs should contain error message, got: $logs" + ) + } + + test("mint with CompiledPlutus produces diagnostic logs on failure") { + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + val assets = Map(AssetName(hex"deadbeef") -> 100L) + + val ex = intercept[TxBuilderException.BalancingException] { + TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + .mint(failingScriptRelease, assets, Data.unit) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + } + + assert(ex.isScriptFailure) + val logs = ex.scriptLogs.get + assert(logs.nonEmpty, "Mint via CompiledPlutus should produce diagnostic logs via replay") + assert( + logs.exists(_.contains("expected failure")), + s"Diagnostic replay logs should contain error message, got: $logs" + ) + } + + test("script hash is preserved in withErrorTraces") { + // withErrorTraces changes the script bytes (adds traces) so it produces a DIFFERENT hash + // The original hash is used for lookup - this is by design since we look up by original hash + val release = failingScriptRelease + val withTraces = release.withErrorTraces + // The SIR is the same + assert(release.sir == withTraces.sir) + // Language is the same + assert(release.language == withTraces.language) + // Options differ only in generateErrorTraces + assert(withTraces.options.generateErrorTraces) + assert(!release.options.generateErrorTraces) + } +} diff --git a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala index 4cb3c2029..14da8da41 100644 --- a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala +++ b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala @@ -279,10 +279,11 @@ class TxBuilderPerformanceTest extends AnyFunSuite, ValidatorRulesTestKit { PlutusScriptEvaluator(testEnv, EvaluatorMode.EvaluateAndComputeCost) override def evalPlutusScriptsWithContexts( tx: Transaction, - utxos: Utxos + utxos: Utxos, + debugScripts: Map[ScriptHash, scalus.uplc.CompiledPlutus[?]] ): Seq[(Redeemer, scalus.cardano.onchain.plutus.ScriptContext, ScriptHash)] = { evaluationCount += 1 - delegate.evalPlutusScriptsWithContexts(tx, utxos) + delegate.evalPlutusScriptsWithContexts(tx, utxos, debugScripts) } } diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala index 48828fc61..67b9d258a 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala @@ -2,6 +2,7 @@ package scalus.cardano.ledger import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{platform, Data} +import scalus.uplc.CompiledPlutus import scalus.cardano.ledger.* import scalus.cardano.ledger.Language.* import scalus.cardano.ledger.LedgerToPlutusTranslation.* @@ -22,7 +23,8 @@ enum EvaluatorMode extends Enum[EvaluatorMode] { class PlutusScriptEvaluationException( message: String, cause: Throwable, - val logs: Array[String] + val logs: Array[String], + val failedScriptHash: ScriptHash ) extends RuntimeException(s"$message\nlogs: ${logs.mkString("\n")}", cause) /** Evaluates Plutus V1, V2 or V3 scripts using the provided transaction and UTxO set. @@ -53,11 +55,13 @@ trait PlutusScriptEvaluator { def evalPlutusScripts( tx: Transaction, utxos: Utxos, - ): Seq[Redeemer] = evalPlutusScriptsWithContexts(tx, utxos).map(_._1) + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + ): Seq[Redeemer] = evalPlutusScriptsWithContexts(tx, utxos, debugScripts).map(_._1) def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Seq[(Redeemer, ScriptContext, ScriptHash)] } @@ -143,7 +147,8 @@ object PlutusScriptEvaluator { @threadUnsafe lazy val noop: PlutusScriptEvaluator = new PlutusScriptEvaluator { override def evalPlutusScriptsWithContexts( tx: Transaction, - utxos: Utxos + utxos: Utxos, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] ): Seq[(Redeemer, ScriptContext, ScriptHash)] = Seq.empty } @@ -205,6 +210,7 @@ object PlutusScriptEvaluator { txhash: String, vm: PlutusVM, plutusScript: PlutusScript, + debugScripts: Map[ScriptHash, CompiledPlutus[?]], args: Data* ): Result = { Result.Success( @@ -319,6 +325,7 @@ object PlutusScriptEvaluator { override def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Seq[(Redeemer, ScriptContext, ScriptHash)] = { log.debug(s"Starting Phase 2 evaluation for transaction: ${tx.id}") @@ -382,13 +389,13 @@ object PlutusScriptEvaluator { val (evaluatedRedeemer, sc) = { val result = plutusScript match case ps: Script.PlutusV1 => - evalPlutusV1Script(tx, txInfoV1, redeemer, ps, datum) + evalPlutusV1Script(tx, txInfoV1, redeemer, ps, datum, debugScripts) case ps: Script.PlutusV2 => - evalPlutusV2Script(tx, txInfoV2, redeemer, ps, datum) + evalPlutusV2Script(tx, txInfoV2, redeemer, ps, datum, debugScripts) case ps: Script.PlutusV3 => - evalPlutusV3Script(tx, txInfoV3, redeemer, ps, datum) + evalPlutusV3Script(tx, txInfoV3, redeemer, ps, datum, debugScripts) val cost = result._1.budget log.debug(s"Evaluation result: $result") @@ -442,7 +449,8 @@ object PlutusScriptEvaluator { txInfoV1: v1.TxInfo, redeemer: Redeemer, plutusScript: PlutusScript, - datum: Option[Data] + datum: Option[Data], + debugScripts: Map[ScriptHash, CompiledPlutus[?]] ): (Result, v1.ScriptContext) = { // Build V1 script context using pre-computed TxInfo val purpose = getScriptPurposeV1(tx, redeemer) @@ -461,6 +469,7 @@ object PlutusScriptEvaluator { txhash, plutusV1VM, plutusScript, + debugScripts, datum.toSeq :+ redeemer.data :+ ctxData* ) -> scriptContext } @@ -483,7 +492,8 @@ object PlutusScriptEvaluator { txInfoV2: v2.TxInfo, redeemer: Redeemer, plutusScript: PlutusScript, - datum: Option[Data] + datum: Option[Data], + debugScripts: Map[ScriptHash, CompiledPlutus[?]] ): (Result, v2.ScriptContext) = { // Build V2 script context using pre-computed TxInfo val purpose = getScriptPurposeV2(tx, redeemer) @@ -502,6 +512,7 @@ object PlutusScriptEvaluator { txhash, plutusV2VM, plutusScript, + debugScripts, datum.toSeq :+ redeemer.data :+ ctxData* ) -> scriptContext } @@ -524,7 +535,8 @@ object PlutusScriptEvaluator { txInfoV3: v3.TxInfo, redeemer: Redeemer, plutusScript: PlutusScript, - datum: Option[Data] + datum: Option[Data], + debugScripts: Map[ScriptHash, CompiledPlutus[?]] ): (Result, v3.ScriptContext) = { // Build V3 script context using pre-computed TxInfo val scriptInfo = getScriptInfoV3(tx, redeemer, datum) @@ -538,7 +550,14 @@ object PlutusScriptEvaluator { log.debug(s"Script context: ${ctxData.toJson}") // V3 scripts only take the script context as argument - evalScript(redeemer, txhash, plutusV3VM, plutusScript, ctxData) -> scriptContext + evalScript( + redeemer, + txhash, + plutusV3VM, + plutusScript, + debugScripts, + ctxData + ) -> scriptContext } /** Execute a UPLC script with the given arguments. @@ -555,6 +574,7 @@ object PlutusScriptEvaluator { txhash: String, vm: PlutusVM, plutusScript: PlutusScript, + debugScripts: Map[ScriptHash, CompiledPlutus[?]], args: Data* ): Result = { // Parse UPLC program from CBOR @@ -580,18 +600,69 @@ object PlutusScriptEvaluator { RestrictingBudgetSpenderWithScriptDump(budget, debugDumpFilesForTesting) val logger = Log() + val hash = plutusScript.scriptHash // Execute the script try val resultTerm = vm.evaluateScript(applied, spender, logger) Result.Success(resultTerm, spender.getSpentBudget, Map.empty, logger.getLogs.toSeq) catch case e: StackTraceMachineError => -// println() -// println(s"Script ${vm.language} ${redeemer.tag} evaluation failed: ${e.getMessage}") -// println(e.env.view.reverse.take(20).mkString("\n")) - throw new PlutusScriptEvaluationException(e.getMessage, e, logger.getLogs) + val logs = logger.getLogs + val finalLogs = + if logs.isEmpty then replayWithDiagnostics(debugScripts, hash, args) + else logs + throw new PlutusScriptEvaluationException( + e.getMessage, + e, + finalLogs, + hash + ) case NonFatal(e) => - throw new PlutusScriptEvaluationException(e.getMessage, e, logger.getLogs) + val logs = logger.getLogs + val finalLogs = + if logs.isEmpty then replayWithDiagnostics(debugScripts, hash, args) + else logs + throw new PlutusScriptEvaluationException( + e.getMessage, + e, + finalLogs, + hash + ) + } + + /** Replay a failed script with error traces enabled to collect diagnostic logs. + * + * When a release script (no traces) fails with empty logs, this method recompiles the + * script from SIR with error traces and replays it to produce diagnostic output. + */ + private def replayWithDiagnostics( + debugScripts: Map[ScriptHash, CompiledPlutus[?]], + hash: ScriptHash, + args: Seq[Data] + ): Array[String] = { + debugScripts.get(hash) match + case None => Array.empty + case Some(compiled) => + try + val debugScript = compiled.withErrorTraces.script + val debugProgram = debugScript.deBruijnedProgram + val debugApplied = args.foldLeft(debugProgram): (acc, arg) => + acc $ arg + val debugVm = debugScript match + case _: Script.PlutusV1 => plutusV1VM + case _: Script.PlutusV2 => plutusV2VM + case _: Script.PlutusV3 => plutusV3VM + val replaySpender = CountingBudgetSpender() + val replayLogger = Log() + var replayFailed = false + try debugVm.evaluateScript(debugApplied, replaySpender, replayLogger) + catch case NonFatal(_) => replayFailed = true + val logs = replayLogger.getLogs + if replayFailed then logs + else logs :+ "[diagnostic replay: debug script succeeded unexpectedly]" + catch + case NonFatal(e) => + Array(s"[diagnostic replay failed: ${e.getMessage}]") } /** Dump script information for debugging purposes. diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala index ee4c37169..13aa395d3 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala @@ -9,6 +9,7 @@ package scalus.cardano.txbuilder import io.bullet.borer.Encoder import monocle.syntax.all.* import monocle.{Focus, Lens} +import scalus.uplc.CompiledPlutus import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data, ToData} import scalus.cardano.address.* @@ -578,7 +579,8 @@ object TransactionBuilder { // into a DiffHandler diffHandler: DiffHandler, protocolParams: ProtocolParams, - evaluator: PlutusScriptEvaluator + evaluator: PlutusScriptEvaluator, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Either[TxBalancingError, Context] = { // println(s"txWithDummySignatures=${HexUtil.encodeHexString(txWithDummySignatures.toCbor)}") @@ -588,7 +590,8 @@ object TransactionBuilder { diffHandler = diffHandler, protocolParams = protocolParams, resolvedUtxo = this.getUtxos, - evaluator = evaluator + evaluator = evaluator, + debugScripts = debugScripts ) // _ = println(HexUtil.encodeHexString(txWithoutDummySignatures.toCbor)) @@ -642,7 +645,8 @@ object TransactionBuilder { def balanceContext( protocolParams: ProtocolParams, diffHandler: DiffHandler, - evaluator: PlutusScriptEvaluator + evaluator: PlutusScriptEvaluator, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Either[SomeBuildError, Context] = { val txWithDummySignatures: Transaction = addDummySignatures(this.expectedSigners.size, this.transaction) @@ -666,7 +670,7 @@ object TransactionBuilder { for { balancedCtx <- contextWithSignatures .ensureMinAdaAll(protocolParams) - .balance(combinedDiffHandler, protocolParams, evaluator) + .balance(combinedDiffHandler, protocolParams, evaluator, debugScripts) .left .map(BalancingError(_, this)) @@ -739,10 +743,11 @@ object TransactionBuilder { evaluator: PlutusScriptEvaluator, validators: Seq[Validator], slot: Long = 1L, - certState: CertState = CertState.empty + certState: CertState = CertState.empty, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Either[SomeBuildError, Context] = { for { - balancedCtx <- balanceContext(protocolParams, diffHandler, evaluator) + balancedCtx <- balanceContext(protocolParams, diffHandler, evaluator, debugScripts) validatedCtx <- balancedCtx.validateContext( validators, protocolParams, @@ -990,13 +995,15 @@ object TransactionBuilder { protocolParams: ProtocolParams, resolvedUtxo: Utxos, evaluator: PlutusScriptEvaluator, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Either[TxBalancingError, Transaction] = { balanceFeeAndChangeWithTokens( initial, Change.changeOutputDiffHandler(_, _, protocolParams, changeOutputIdx), protocolParams, resolvedUtxo, - evaluator + evaluator, + debugScripts ) } @@ -1015,6 +1022,7 @@ object TransactionBuilder { protocolParams: ProtocolParams, resolvedUtxo: Utxos, evaluator: PlutusScriptEvaluator, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ): Either[TxBalancingError, Transaction] = { var iteration = 0 @@ -1024,7 +1032,12 @@ object TransactionBuilder { return Left(TxBalancingError.BalanceDidNotConverge(iteration)) val eTrialTx = for { - txWithExUnits <- computeScriptsWitness(resolvedUtxo, evaluator, protocolParams)(tx) + txWithExUnits <- computeScriptsWitness( + resolvedUtxo, + evaluator, + protocolParams, + debugScripts + )(tx) minFee <- MinTransactionFee .ensureMinFee(txWithExUnits, resolvedUtxo, protocolParams) .left @@ -1048,9 +1061,10 @@ object TransactionBuilder { private[txbuilder] def computeScriptsWitness( utxos: Utxos, evaluator: PlutusScriptEvaluator, - protocolParams: ProtocolParams + protocolParams: ProtocolParams, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty )(tx: Transaction): Either[TxBalancingError, Transaction] = Try { - val redeemers = evaluator.evalPlutusScripts(tx, utxos) + val redeemers = evaluator.evalPlutusScripts(tx, utxos, debugScripts) setupRedeemers(protocolParams, tx, utxos, redeemers) }.toEither.left.map { case psee: PlutusScriptEvaluationException => TxBalancingError.EvaluationFailed(psee) diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala index 00b335868..dda86ea79 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala @@ -1,5 +1,6 @@ package scalus.cardano.txbuilder +import scalus.uplc.CompiledPlutus import scalus.uplc.builtin.Builtins.{blake2b_256, serialiseData} import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{Data, ToData} @@ -179,7 +180,8 @@ case class TxBuilder( evaluator: PlutusScriptEvaluator, steps: Seq[TransactionBuilderStep] = Seq.empty, attachedData: Map[DataHash, Data] = Map.empty, - changeOutputIndex: Option[Int] = None + changeOutputIndex: Option[Int] = None, + debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty ) { /** Spends a UTXO with an explicit witness. @@ -399,6 +401,86 @@ case class TxBuilder( addSteps(TransactionBuilderStep.Spend(utxo, witness)) } + /** Spends a script-protected UTXO using a [[CompiledPlutus]] script. + * + * The compiled script is used both for the on-chain script (via `.script`) and registered as a + * debug script for diagnostic replay. If the script was compiled with `Options.release` (no + * traces) and fails, the evaluator will automatically recompile from SIR with error traces and + * replay to produce diagnostic logs. + * + * @param utxo + * the UTXO to spend + * @param redeemer + * redeemer to pass to the script + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + */ + def spend[T: ToData]( + utxo: Utxo, + redeemer: T, + compiled: CompiledPlutus[?] + ): TxBuilder = { + registerDebugScript(compiled).spend(utxo, redeemer, compiled.script) + } + + /** Spends a script-protected UTXO using a [[CompiledPlutus]] script with required signers. + * + * @param utxo + * the UTXO to spend + * @param redeemer + * redeemer to pass to the script + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + * @param requiredSigners + * set of public key hashes that must sign the transaction + */ + def spend[T: ToData]( + utxo: Utxo, + redeemer: T, + compiled: CompiledPlutus[?], + requiredSigners: Set[AddrKeyHash] + ): TxBuilder = { + registerDebugScript(compiled).spend(utxo, redeemer, compiled.script, requiredSigners) + } + + /** Spends a script-protected UTXO with a delayed redeemer using a [[CompiledPlutus]] script. + * + * @param utxo + * the UTXO to spend + * @param redeemerBuilder + * function that computes the redeemer from the assembled transaction + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + */ + def spend( + utxo: Utxo, + redeemerBuilder: Transaction => Data, + compiled: CompiledPlutus[?] + ): TxBuilder = { + registerDebugScript(compiled).spend(utxo, redeemerBuilder, compiled.script) + } + + /** Spends a script-protected UTXO with a delayed redeemer using a [[CompiledPlutus]] script + * with required signers. + * + * @param utxo + * the UTXO to spend + * @param redeemerBuilder + * function that computes the redeemer from the assembled transaction + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + * @param requiredSigners + * set of public key hashes that must sign the transaction + */ + def spend( + utxo: Utxo, + redeemerBuilder: Transaction => Data, + compiled: CompiledPlutus[?], + requiredSigners: Set[AddrKeyHash] + ): TxBuilder = { + registerDebugScript(compiled).spend(utxo, redeemerBuilder, compiled.script, requiredSigners) + } + /** Adds the specified utxos to the list of reference inputs. * * Reference inputs allow scripts to read UTXOs without consuming them. @@ -406,6 +488,22 @@ case class TxBuilder( def references(utxo: Utxo, rest: Utxo*): TxBuilder = addSteps((utxo :: rest.toList).map(TransactionBuilderStep.ReferenceOutput.apply)*) + /** Adds the specified utxo to the list of reference inputs and registers the compiled script + * for diagnostic replay. + * + * Use this overload when the reference UTXO contains a script and you want diagnostic replay + * support. The compiled script is registered so that if the release script fails with empty + * logs, the evaluator can recompile from SIR with error traces. + * + * @param utxo + * the reference UTXO + * @param compiled + * the compiled Plutus script for diagnostic replay + */ + def references(utxo: Utxo, compiled: CompiledPlutus[?]): TxBuilder = + registerDebugScript(compiled) + .addSteps(TransactionBuilderStep.ReferenceOutput(utxo)) + /** Adds the specified utxos to the list of collateral inputs. * * Collateral inputs are used to cover transaction fees if script execution fails. They are @@ -657,6 +755,63 @@ case class TxBuilder( addSteps(mintSteps*) } + /** Mints or burns native tokens using a [[CompiledPlutus]] script. + * + * The compiled script is used both for the on-chain script and registered as a debug script + * for diagnostic replay. + * + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + * @param assets + * map of asset names to amounts (positive for minting, negative for burning) + * @param redeemer + * redeemer to pass to the minting policy script + */ + def mint[T: ToData]( + compiled: CompiledPlutus[?], + assets: collection.Map[AssetName, Long], + redeemer: T + ): TxBuilder = { + registerDebugScript(compiled).mint(compiled.script, assets, redeemer) + } + + /** Mints or burns native tokens using a [[CompiledPlutus]] script with required signers. + * + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + * @param assets + * map of asset names to amounts (positive for minting, negative for burning) + * @param redeemer + * redeemer to pass to the minting policy script + * @param requiredSigners + * set of public key hashes that must sign the transaction + */ + def mint[T: ToData]( + compiled: CompiledPlutus[?], + assets: collection.Map[AssetName, Long], + redeemer: T, + requiredSigners: Set[AddrKeyHash] + ): TxBuilder = { + registerDebugScript(compiled).mint(compiled.script, assets, redeemer, requiredSigners) + } + + /** Mints or burns native tokens with a delayed redeemer using a [[CompiledPlutus]] script. + * + * @param compiled + * compiled Plutus script (carries SIR for diagnostic replay) + * @param assets + * map of asset names to amounts (positive for minting, negative for burning) + * @param redeemerBuilder + * function that computes the redeemer from the assembled transaction + */ + def mint( + compiled: CompiledPlutus[?], + assets: collection.Map[AssetName, Long], + redeemerBuilder: Transaction => Data + ): TxBuilder = { + registerDebugScript(compiled).mint(compiled.script, assets, redeemerBuilder) + } + /** Mints or burns native tokens with a delayed redeemer and attached script. * * Use this method when the redeemer depends on the final transaction structure (e.g., for @@ -1338,7 +1493,7 @@ case class TxBuilder( val balancedContext = for { built <- TransactionBuilder.modify(context, steps) withAttachments = addAttachmentsToContext(built) - balanced <- withAttachments.balanceContext(params, diffHandler, evaluator) + balanced <- withAttachments.balanceContext(params, diffHandler, evaluator, debugScripts) } yield balanced balancedContext match { @@ -1590,7 +1745,8 @@ case class TxBuilder( ctxWithSponsor.balanceContext( env.protocolParams, diffHandler, - evaluator + evaluator, + debugScripts ) match { case Right(balancedCtx) => if balancedCtx.transaction.body.value.inputs.toSeq.isEmpty && pool.remainingForInputs.nonEmpty @@ -1733,6 +1889,27 @@ case class TxBuilder( ctx.copy(transaction = updatedTx) } + /** Registers a compiled script for diagnostic replay. + * + * When a release script (compiled without error traces) fails during evaluation with empty + * logs, the evaluator will use the registered compiled script to recompile from SIR with error + * traces and replay the failing evaluation, producing diagnostic logs. + * + * This is automatically called by the `spend` and `mint` overloads that accept + * [[CompiledPlutus]]. Use this method directly for reference-script use cases where the script + * is not attached to the transaction but you still want diagnostic replay. + * + * @param compiled + * the compiled Plutus script to register for diagnostic replay + */ + def withDebugScript(compiled: CompiledPlutus[?]): TxBuilder = { + registerDebugScript(compiled) + } + + private def registerDebugScript(compiled: CompiledPlutus[?]): TxBuilder = { + copy(debugScripts = debugScripts + (compiled.script.scriptHash -> compiled)) + } + /** Appends transaction building steps to this builder. * * This is the low-level method for adding steps. Prefer using the high-level methods like diff --git a/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala b/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala index 0d4094645..66f4140d1 100644 --- a/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala +++ b/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala @@ -98,6 +98,16 @@ sealed abstract class CompiledPlutus[A]( /** Creates the script with the appropriate version. */ protected def makeScript(program: Program): PlutusScript + /** Returns a copy of this compiled script with error traces enabled. + * + * Error traces provide detailed error messages during script evaluation, useful for debugging. + * This increases script size and execution cost. + * + * @return + * a new [[CompiledPlutus]] with `generateErrorTraces = true` + */ + def withErrorTraces: CompiledPlutus[A] + /** Lowers the SIR to UPLC using the configured backend and applies optimization if enabled. */ protected def toUplc: Term = { val backend = options.targetLoweringBackend diff --git a/scalus-site/content/testing/debugging.mdx b/scalus-site/content/testing/debugging.mdx index 732ef969e..b237dceae 100644 --- a/scalus-site/content/testing/debugging.mdx +++ b/scalus-site/content/testing/debugging.mdx @@ -114,11 +114,22 @@ object MyValidator extends Validator: ## Where Do Logs Appear? -When scripts are evaluated (on-chain or via `PlutusScriptEvaluator`), logs are collected and included in: +**Local evaluation** (via `PlutusScriptEvaluator` or `evaluateDebug`): logs are always collected and included in: - The `Result.Success` object (when evaluation succeeds) - The `PlutusScriptEvaluationException` (when evaluation fails) -Off-chain, you can access logs through the evaluation result to understand what happened during script execution. This is particularly useful when debugging why a transaction failed validation. +This is the most reliable way to see trace output. Use `evaluateDebug` or the Emulator +to inspect logs during development. + +**Node-side** (when submitting to a Cardano node): trace logs from failed scripts +appear in the node error response as `"Script debugging logs: ..."`, but **only when +the node runs in Verbose mode**. Yaci DevKit defaults to Quiet mode where trace output +is not included in error responses. Blockfrost-connected nodes (Preprod, Mainnet) +typically run in Verbose mode. + +To see trace logs from a Yaci DevKit node, the node must be configured with +`VerboseMode = Verbose`. Note that successful script evaluations never expose +trace output on-chain — only failures include logs in the error response. ## Evaluating with Error Traces @@ -141,6 +152,57 @@ val result = compiled.toUplc(generateErrorTraces = true).evaluateDebug - `true`: Adds error location information (useful for debugging, but increases script size) - `false`: Minimal script size (for production deployment) +## Diagnostic Replay for Release Scripts + +When deploying to production, you typically compile scripts with `Options.release` (no error traces) to minimize script size and execution costs. However, if a release script fails, the error logs will be empty — making it hard to diagnose the issue. + +**Diagnostic replay** solves this: when you use `CompiledPlutus` (e.g., `PlutusV3.compile(...)`) with `TxBuilder`, the builder automatically registers the compiled script for replay. If the release script fails with empty logs, the evaluator: + +1. Recompiles the script from SIR with error traces enabled +2. Replays the failing evaluation with the same arguments +3. Collects the diagnostic logs from the replay +4. Includes them in the `PlutusScriptEvaluationException` + +### Using Diagnostic Replay with TxBuilder + +Pass `CompiledPlutus` (the result of `PlutusV3.compile(...)`) instead of `PlutusScript` to `spend` or `mint`: + +```scala +given Options = Options.release // no traces for production + +val validator = PlutusV3.compile { (sc: Data) => + // your validator code + require(someCondition, "Condition failed") +} + +// Using CompiledPlutus enables automatic diagnostic replay +val tx = TxBuilder(env) + .spend(scriptUtxo, redeemer, validator) // not validator.script! + .payTo(recipient, Value.ada(10)) + .build(changeTo = changeAddress) +``` + +For reference scripts, use the `references` overload: + +```scala +val tx = TxBuilder(env) + .references(scriptRefUtxo, validator) // registers for replay + .spend(scriptUtxo, redeemer) + .build(changeTo = changeAddress) +``` + +When the script fails, the exception will contain diagnostic logs even though the on-chain script has no traces: + +```scala +try { + builder.build(changeTo = changeAddress) +} catch { + case e: TxBuilderException.BalancingException => + e.scriptLogs.foreach(logs => println(logs.mkString("\n"))) + // Prints: "Condition failed" (from diagnostic replay) +} +``` + ## Debugging with IDE One of Scalus's biggest advantages is the ability to debug validators as regular Scala code: From 140b9a7f8ba591bb1cd991c270b0d3607c1ac089 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Mon, 16 Feb 2026 18:22:21 +0200 Subject: [PATCH 2/4] feat: add DebugScript API for external tx builders Replace Map[ScriptHash, CompiledPlutus[?]] with Map[ScriptHash, DebugScript] throughout the diagnostic replay pipeline. DebugScript wraps either a pre-compiled debug PlutusScript (for external builders like meshJS) or a lazy recompilation from CompiledPlutus (for Scalus TxBuilder). - Add DebugScript class with apply(PlutusScript) and fromCompiled(CompiledPlutus) - Add debugScripts field to rules.Context for Emulator path - Add submitSync(tx, debugScripts) overload to JVM/JS Emulator - Add submitTx(txBytes, debugScripts) JS-friendly overload to JEmulator - Propagate scriptHash in PlutusScriptValidationException and SubmitError - Add TxBuilder.withDebugScript(scriptHash, DebugScript) overload --- .../scala/scalus/cardano/node/Emulator.scala | 15 ++++ .../scala/scalus/cardano/node/JEmulator.scala | 53 +++++++++++- .../scala/scalus/cardano/node/Emulator.scala | 19 +++++ .../txbuilder/DiagnosticReplayTest.scala | 84 ++++++++++++++++++- .../txbuilder/TxBuilderPerformanceTest.scala | 2 +- .../scalus/cardano/ledger/Entities.scala | 3 +- .../ledger/PlutusScriptEvaluator.scala | 26 +++--- .../cardano/ledger/rules/Entities.scala | 5 +- .../PlutusScriptsTransactionMutator.scala | 5 +- .../cardano/node/BlockchainProvider.scala | 2 +- .../scalus/cardano/node/EmulatorBase.scala | 23 +++++ .../txbuilder/TransactionBuilder.scala | 14 ++-- .../scalus/cardano/txbuilder/TxBuilder.scala | 27 +++++- .../main/scala/scalus/uplc/DebugScript.scala | 45 ++++++++++ 14 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 scalus-core/shared/src/main/scala/scalus/uplc/DebugScript.scala diff --git a/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/Emulator.scala b/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/Emulator.scala index be9600863..e1b2c1c4b 100644 --- a/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/Emulator.scala +++ b/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/Emulator.scala @@ -1,5 +1,6 @@ package scalus.cardano.node +import scalus.uplc.DebugScript import scalus.cardano.address.Address import scalus.cardano.ledger.rules.{Context, DefaultMutators, DefaultValidators, STS, State} import scalus.cardano.ledger.* @@ -36,6 +37,20 @@ class Emulator( } } + def submitSync( + transaction: Transaction, + debugScripts: Map[ScriptHash, DebugScript] + ): Either[SubmitError, TransactionHash] = { + val ctxWithDebug = context.copy(debugScripts = debugScripts) + processTransaction(ctxWithDebug, state, transaction) match { + case Right(newState) => + state = newState + Right(transaction.id) + case Left(t: TransactionException) => + Left(SubmitError.fromException(t)) + } + } + def setSlot(slot: SlotNo): Unit = { context = Context( fee = context.fee, diff --git a/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala b/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala index 35c8141ca..b0046a027 100644 --- a/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala +++ b/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala @@ -1,9 +1,12 @@ package scalus.cardano.node import io.bullet.borer.Cbor +import scalus.uplc.DebugScript +import scalus.uplc.builtin.ByteString import scalus.cardano.address.Address import scalus.cardano.ledger.rules.{Context, UtxoEnv} import scalus.cardano.ledger.* +import scalus.cardano.ledger.utils.AllResolvedScripts import scala.scalajs.js import scala.scalajs.js.annotation.{JSExportStatic, JSExportTopLevel} @@ -40,7 +43,54 @@ class JEmulator( */ def submitTx(txCborBytes: Uint8Array): js.Dynamic = { val tx = Transaction.fromCbor(txCborBytes.toArray.map(_.toByte)) - emulator.submitSync(tx) match { + formatSubmitResult(emulator.submitSync(tx)) + } + + /** Submit a transaction with debug scripts for diagnostic replay. + * + * Debug scripts are provided as a dictionary mapping script hash hex to double-CBOR hex of the + * debug-compiled script. The language version is resolved from the release script in the + * transaction. + * + * @param txCborBytes + * CBOR-encoded transaction bytes + * @param debugScripts + * dictionary mapping scriptHashHex to doubleCborHex of debug script + * @return + * Object with isSuccess, txHash (on success), or error and logs (on failure) + */ + def submitTx(txCborBytes: Uint8Array, debugScripts: js.Dictionary[String]): js.Dynamic = { + val tx = Transaction.fromCbor(txCborBytes.toArray.map(_.toByte)) + + // Resolve scripts from the transaction to determine language versions + val resolvedScripts = AllResolvedScripts.allResolvedScriptsMap(tx, emulator.utxos) match + case Right(map) => map + case Left(error) => Map.empty[ScriptHash, Script] + + // Parse debug scripts dictionary + val debugScriptsMap: Map[ScriptHash, DebugScript] = debugScripts.flatMap { + case (hashHex, doubleCborHex) => + val hash = ScriptHash.fromHex(hashHex) + val doubleCbor = ByteString.fromHex(doubleCborHex) + // Determine language from the release script in the transaction + val languageOpt = resolvedScripts.get(hash).collect { case ps: PlutusScript => + ps.language + } + languageOpt.map { language => + val plutusScript: PlutusScript = language match + case Language.PlutusV1 => Script.PlutusV1(doubleCbor) + case Language.PlutusV2 => Script.PlutusV2(doubleCbor) + case Language.PlutusV3 => Script.PlutusV3(doubleCbor) + case _ => Script.PlutusV3(doubleCbor) + hash -> DebugScript(plutusScript) + } + }.toMap + + formatSubmitResult(emulator.submitSync(tx, debugScriptsMap)) + } + + private def formatSubmitResult(result: Either[SubmitError, TransactionHash]): js.Dynamic = + result match { case Right(txHash) => js.Dynamic.literal(isSuccess = true, txHash = txHash.toHex) case Left(submitError) => @@ -55,7 +105,6 @@ class JEmulator( js.Dynamic.literal(isSuccess = false, error = submitError.message) } } - } /** Get all UTxOs as CBOR. */ def getUtxosCbor(): Uint8Array = { diff --git a/scalus-cardano-ledger/jvm/src/main/scala/scalus/cardano/node/Emulator.scala b/scalus-cardano-ledger/jvm/src/main/scala/scalus/cardano/node/Emulator.scala index a3a655c6d..584e96c81 100644 --- a/scalus-cardano-ledger/jvm/src/main/scala/scalus/cardano/node/Emulator.scala +++ b/scalus-cardano-ledger/jvm/src/main/scala/scalus/cardano/node/Emulator.scala @@ -1,5 +1,6 @@ package scalus.cardano.node +import scalus.uplc.DebugScript import scalus.cardano.address.Address import scalus.cardano.ledger.rules.{Context, DefaultMutators, DefaultValidators, STS, State} import scalus.cardano.ledger.* @@ -44,6 +45,24 @@ class Emulator( } } + @tailrec + final def submitSync( + transaction: Transaction, + debugScripts: Map[ScriptHash, DebugScript] + ): Either[SubmitError, TransactionHash] = { + val currentState = stateRef.get() + val ctx = contextRef.get() + val ctxWithDebug = ctx.copy(debugScripts = debugScripts) + + processTransaction(ctxWithDebug, currentState, transaction) match { + case Right(newState) => + if stateRef.compareAndSet(currentState, newState) then Right(transaction.id) + else submitSync(transaction, debugScripts) + case Left(t: TransactionException) => + Left(SubmitError.fromException(t)) + } + } + @tailrec final def setSlot(slot: SlotNo): Unit = { val ctx = contextRef.get() diff --git a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala index 9fe0c77ab..ec6abdd68 100644 --- a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala +++ b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala @@ -1,16 +1,17 @@ package scalus.cardano.txbuilder import org.scalatest.funsuite.AnyFunSuite +import scalus.uplc.{DebugScript, PlutusV3} import scalus.uplc.builtin.ByteString.hex import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data} import scalus.cardano.address.Address import scalus.cardano.address.Network.Mainnet import scalus.cardano.ledger.* +import scalus.cardano.node.{Emulator, NodeSubmitError} import scalus.compiler.Options import scalus.testing.kit.Party.{Alice, Bob} import scalus.testing.kit.TestUtil.genAdaOnlyPubKeyUtxo -import scalus.uplc.PlutusV3 class DiagnosticReplayTest extends AnyFunSuite { @@ -214,4 +215,85 @@ class DiagnosticReplayTest extends AnyFunSuite { assert(withTraces.options.generateErrorTraces) assert(!release.options.generateErrorTraces) } + + test("DebugScript from raw PlutusScript via TxBuilder withDebugScript") { + val scriptUtxo = createScriptLockedUtxo(failingScriptRelease.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + // Use raw PlutusScript for debug (the version with traces) + val debugPlutusScript = failingScriptWithTraces.script + val releaseHash = failingScriptRelease.script.scriptHash + + val ex = intercept[TxBuilderException.BalancingException] { + TxBuilder(env) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + // Spend with raw PlutusScript (no CompiledPlutus) + .spend(scriptUtxo, Data.unit, failingScriptRelease.script) + // Register debug script from raw PlutusScript + .withDebugScript(releaseHash, DebugScript(debugPlutusScript)) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + } + + assert(ex.isScriptFailure) + val logs = ex.scriptLogs.get + assert( + logs.nonEmpty, + "DebugScript from raw PlutusScript should produce diagnostic logs via replay" + ) + assert( + logs.exists(_.contains("expected failure")), + s"Diagnostic replay logs should contain error message, got: $logs" + ) + } + + test("DebugScript from raw PlutusScript via Emulator submitSync") { + val scriptUtxo = createScriptLockedUtxo(failingScriptRelease.script) + val paymentUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(50)).sample.get + val collateralUtxo = genAdaOnlyPubKeyUtxo(Alice, min = Coin.ada(5)).sample.get + + // Create emulator with the UTXOs + val emulator = Emulator( + initialUtxos = Map( + scriptUtxo.input -> scriptUtxo.output, + paymentUtxo.input -> paymentUtxo.output, + collateralUtxo.input -> collateralUtxo.output + ) + ) + + // Build tx with noop evaluator (so build doesn't fail on script eval) + // and sign with Alice's key + val tx = TxBuilder(env, PlutusScriptEvaluator.noop) + .spend(paymentUtxo) + .collaterals(collateralUtxo) + .spend(scriptUtxo, Data.unit, failingScriptRelease.script) + .payTo(Bob.address, Value.ada(1)) + .build(changeTo = Alice.address) + .sign(Alice.signer) + .transaction + + // Submit with debug scripts + val debugPlutusScript = failingScriptWithTraces.script + val releaseHash = failingScriptRelease.script.scriptHash + val debugScripts = Map(releaseHash -> DebugScript(debugPlutusScript)) + + val result = emulator.submitSync(tx, debugScripts) + + assert(result.isLeft, "Transaction should fail") + result.left.foreach { + case NodeSubmitError.ScriptFailure(_, _, logs) => + assert( + logs.nonEmpty, + "Emulator submitSync with debug scripts should produce diagnostic logs" + ) + assert( + logs.exists(_.contains("expected failure")), + s"Diagnostic logs should contain error message, got: $logs" + ) + case other => + fail(s"Expected ScriptFailure, got: $other") + } + } } diff --git a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala index 14da8da41..65f5ec0bf 100644 --- a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala +++ b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/TxBuilderPerformanceTest.scala @@ -280,7 +280,7 @@ class TxBuilderPerformanceTest extends AnyFunSuite, ValidatorRulesTestKit { override def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, - debugScripts: Map[ScriptHash, scalus.uplc.CompiledPlutus[?]] + debugScripts: Map[ScriptHash, scalus.uplc.DebugScript] ): Seq[(Redeemer, scalus.cardano.onchain.plutus.ScriptContext, ScriptHash)] = { evaluationCount += 1 delegate.evalPlutusScriptsWithContexts(tx, utxos, debugScripts) diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/Entities.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/Entities.scala index f0f410292..7ec23e3ae 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/Entities.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/Entities.scala @@ -400,7 +400,8 @@ object TransactionException { final case class PlutusScriptValidationException( transactionId: TransactionHash, message: String, - logs: Seq[String] + logs: Seq[String], + scriptHash: Option[ScriptHash] = None ) extends TransactionException( s"Plutus script validation failed for transactionId $transactionId: $message" ) diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala index 67b9d258a..ddee5e12f 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala @@ -2,7 +2,7 @@ package scalus.cardano.ledger import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{platform, Data} -import scalus.uplc.CompiledPlutus +import scalus.uplc.DebugScript import scalus.cardano.ledger.* import scalus.cardano.ledger.Language.* import scalus.cardano.ledger.LedgerToPlutusTranslation.* @@ -55,13 +55,13 @@ trait PlutusScriptEvaluator { def evalPlutusScripts( tx: Transaction, utxos: Utxos, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Seq[Redeemer] = evalPlutusScriptsWithContexts(tx, utxos, debugScripts).map(_._1) def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Seq[(Redeemer, ScriptContext, ScriptHash)] } @@ -148,7 +148,7 @@ object PlutusScriptEvaluator { override def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] + debugScripts: Map[ScriptHash, DebugScript] ): Seq[(Redeemer, ScriptContext, ScriptHash)] = Seq.empty } @@ -210,7 +210,7 @@ object PlutusScriptEvaluator { txhash: String, vm: PlutusVM, plutusScript: PlutusScript, - debugScripts: Map[ScriptHash, CompiledPlutus[?]], + debugScripts: Map[ScriptHash, DebugScript], args: Data* ): Result = { Result.Success( @@ -325,7 +325,7 @@ object PlutusScriptEvaluator { override def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Seq[(Redeemer, ScriptContext, ScriptHash)] = { log.debug(s"Starting Phase 2 evaluation for transaction: ${tx.id}") @@ -450,7 +450,7 @@ object PlutusScriptEvaluator { redeemer: Redeemer, plutusScript: PlutusScript, datum: Option[Data], - debugScripts: Map[ScriptHash, CompiledPlutus[?]] + debugScripts: Map[ScriptHash, DebugScript] ): (Result, v1.ScriptContext) = { // Build V1 script context using pre-computed TxInfo val purpose = getScriptPurposeV1(tx, redeemer) @@ -493,7 +493,7 @@ object PlutusScriptEvaluator { redeemer: Redeemer, plutusScript: PlutusScript, datum: Option[Data], - debugScripts: Map[ScriptHash, CompiledPlutus[?]] + debugScripts: Map[ScriptHash, DebugScript] ): (Result, v2.ScriptContext) = { // Build V2 script context using pre-computed TxInfo val purpose = getScriptPurposeV2(tx, redeemer) @@ -536,7 +536,7 @@ object PlutusScriptEvaluator { redeemer: Redeemer, plutusScript: PlutusScript, datum: Option[Data], - debugScripts: Map[ScriptHash, CompiledPlutus[?]] + debugScripts: Map[ScriptHash, DebugScript] ): (Result, v3.ScriptContext) = { // Build V3 script context using pre-computed TxInfo val scriptInfo = getScriptInfoV3(tx, redeemer, datum) @@ -574,7 +574,7 @@ object PlutusScriptEvaluator { txhash: String, vm: PlutusVM, plutusScript: PlutusScript, - debugScripts: Map[ScriptHash, CompiledPlutus[?]], + debugScripts: Map[ScriptHash, DebugScript], args: Data* ): Result = { // Parse UPLC program from CBOR @@ -636,15 +636,15 @@ object PlutusScriptEvaluator { * script from SIR with error traces and replays it to produce diagnostic output. */ private def replayWithDiagnostics( - debugScripts: Map[ScriptHash, CompiledPlutus[?]], + debugScripts: Map[ScriptHash, DebugScript], hash: ScriptHash, args: Seq[Data] ): Array[String] = { debugScripts.get(hash) match case None => Array.empty - case Some(compiled) => + case Some(ds) => try - val debugScript = compiled.withErrorTraces.script + val debugScript = ds.plutusScript val debugProgram = debugScript.deBruijnedProgram val debugApplied = args.foldLeft(debugProgram): (acc, arg) => acc $ arg diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/Entities.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/Entities.scala index eedc0962e..96aa07d25 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/Entities.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/Entities.scala @@ -2,6 +2,7 @@ package scalus.cardano.ledger package rules import scalus.cardano.address.Network +import scalus.uplc.DebugScript import scala.annotation.threadUnsafe @@ -14,7 +15,9 @@ case class Context( * - `Validate`: Enforce budget limits (default, production mode) * - `EvaluateAndComputeCost`: Ignore budget limits, just compute costs (for testing) */ - evaluatorMode: EvaluatorMode = EvaluatorMode.Validate + evaluatorMode: EvaluatorMode = EvaluatorMode.Validate, + /** Debug scripts for diagnostic replay when release scripts fail with empty logs. */ + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ) object Context { diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/PlutusScriptsTransactionMutator.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/PlutusScriptsTransactionMutator.scala index eeb17a8a5..eb28f4a4d 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/PlutusScriptsTransactionMutator.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/rules/PlutusScriptsTransactionMutator.scala @@ -30,7 +30,7 @@ object PlutusScriptsTransactionMutator extends STS.Mutator { protocolMajorVersion = protocolVersion.toMajor, costModels = protocolParameters.costModels, mode = context.evaluatorMode - ).evalPlutusScripts(event, utxo) + ).evalPlutusScripts(event, utxo, context.debugScripts) if event.isValid then val addedUtxos: Utxos = event.body.value.outputs.view.zipWithIndex.map { @@ -60,7 +60,8 @@ object PlutusScriptsTransactionMutator extends STS.Mutator { TransactionException.PlutusScriptValidationException( event.id, e.getMessage, - e.logs.toSeq + e.logs.toSeq, + Some(e.failedScriptHash) ) ) else diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala index bc2926672..1bf3b3ce7 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala @@ -509,7 +509,7 @@ object SubmitError { case e: TransactionException.NativeScriptsException => ScriptFailure(e.explain) case e: TransactionException.PlutusScriptValidationException => - ScriptFailure(e.explain, logs = e.logs) + ScriptFailure(e.explain, scriptHash = e.scriptHash, logs = e.logs) case e => ValidationError(e.explain) } diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/EmulatorBase.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/EmulatorBase.scala index 91a05bd33..1b7398566 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/EmulatorBase.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/EmulatorBase.scala @@ -1,5 +1,6 @@ package scalus.cardano.node +import scalus.uplc.DebugScript import scalus.uplc.builtin.ByteString import scalus.cardano.address.Address import scalus.cardano.ledger.rules.{Context, STS, State} @@ -44,6 +45,28 @@ trait EmulatorBase extends BlockchainProvider { def submit(transaction: Transaction): Future[Either[SubmitError, TransactionHash]] = Future.successful(submitSync(transaction)) + /** Submit a transaction with debug scripts for diagnostic replay. + * + * When a release script fails with empty logs, the evaluator replays it using the debug script + * to produce diagnostic output. + * + * @param transaction + * the transaction to submit + * @param debugScripts + * map from release script hash to debug script for diagnostic replay + */ + def submit( + transaction: Transaction, + debugScripts: Map[ScriptHash, DebugScript] + ): Future[Either[SubmitError, TransactionHash]] = + Future.successful(submitSync(transaction, debugScripts)) + + /** Synchronously submit a transaction with debug scripts for diagnostic replay. */ + def submitSync( + transaction: Transaction, + debugScripts: Map[ScriptHash, DebugScript] + ): Either[SubmitError, TransactionHash] + def findUtxos(query: UtxoQuery): Future[Either[UtxoQueryError, Utxos]] = Future.successful(Right(EmulatorBase.evalQuery(utxos, query))) diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala index 13aa395d3..854ee1729 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TransactionBuilder.scala @@ -9,7 +9,7 @@ package scalus.cardano.txbuilder import io.bullet.borer.Encoder import monocle.syntax.all.* import monocle.{Focus, Lens} -import scalus.uplc.CompiledPlutus +import scalus.uplc.DebugScript import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{ByteString, Data, ToData} import scalus.cardano.address.* @@ -580,7 +580,7 @@ object TransactionBuilder { diffHandler: DiffHandler, protocolParams: ProtocolParams, evaluator: PlutusScriptEvaluator, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Either[TxBalancingError, Context] = { // println(s"txWithDummySignatures=${HexUtil.encodeHexString(txWithDummySignatures.toCbor)}") @@ -646,7 +646,7 @@ object TransactionBuilder { protocolParams: ProtocolParams, diffHandler: DiffHandler, evaluator: PlutusScriptEvaluator, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Either[SomeBuildError, Context] = { val txWithDummySignatures: Transaction = addDummySignatures(this.expectedSigners.size, this.transaction) @@ -744,7 +744,7 @@ object TransactionBuilder { validators: Seq[Validator], slot: Long = 1L, certState: CertState = CertState.empty, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Either[SomeBuildError, Context] = { for { balancedCtx <- balanceContext(protocolParams, diffHandler, evaluator, debugScripts) @@ -995,7 +995,7 @@ object TransactionBuilder { protocolParams: ProtocolParams, resolvedUtxo: Utxos, evaluator: PlutusScriptEvaluator, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Either[TxBalancingError, Transaction] = { balanceFeeAndChangeWithTokens( initial, @@ -1022,7 +1022,7 @@ object TransactionBuilder { protocolParams: ProtocolParams, resolvedUtxo: Utxos, evaluator: PlutusScriptEvaluator, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ): Either[TxBalancingError, Transaction] = { var iteration = 0 @@ -1062,7 +1062,7 @@ object TransactionBuilder { utxos: Utxos, evaluator: PlutusScriptEvaluator, protocolParams: ProtocolParams, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty )(tx: Transaction): Either[TxBalancingError, Transaction] = Try { val redeemers = evaluator.evalPlutusScripts(tx, utxos, debugScripts) setupRedeemers(protocolParams, tx, utxos, redeemers) diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala index dda86ea79..131b798c4 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala @@ -1,6 +1,6 @@ package scalus.cardano.txbuilder -import scalus.uplc.CompiledPlutus +import scalus.uplc.{CompiledPlutus, DebugScript} import scalus.uplc.builtin.Builtins.{blake2b_256, serialiseData} import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{Data, ToData} @@ -181,7 +181,7 @@ case class TxBuilder( steps: Seq[TransactionBuilderStep] = Seq.empty, attachedData: Map[DataHash, Data] = Map.empty, changeOutputIndex: Option[Int] = None, - debugScripts: Map[ScriptHash, CompiledPlutus[?]] = Map.empty + debugScripts: Map[ScriptHash, DebugScript] = Map.empty ) { /** Spends a UTXO with an explicit witness. @@ -1889,6 +1889,25 @@ case class TxBuilder( ctx.copy(transaction = updatedTx) } + /** Registers a debug script for diagnostic replay. + * + * When a release script (compiled without error traces) fails during evaluation with empty + * logs, the evaluator will use the registered debug script to replay the failing evaluation, + * producing diagnostic logs. + * + * This overload accepts a [[DebugScript]] directly, which can wrap either a pre-compiled debug + * [[scalus.cardano.ledger.PlutusScript]] (for external builders) or a lazy recompilation from + * [[CompiledPlutus]]. + * + * @param scriptHash + * the script hash of the release script (used for lookup) + * @param debugScript + * the debug script to use for replay + */ + def withDebugScript(scriptHash: ScriptHash, debugScript: DebugScript): TxBuilder = { + copy(debugScripts = debugScripts + (scriptHash -> debugScript)) + } + /** Registers a compiled script for diagnostic replay. * * When a release script (compiled without error traces) fails during evaluation with empty @@ -1907,7 +1926,9 @@ case class TxBuilder( } private def registerDebugScript(compiled: CompiledPlutus[?]): TxBuilder = { - copy(debugScripts = debugScripts + (compiled.script.scriptHash -> compiled)) + copy(debugScripts = + debugScripts + (compiled.script.scriptHash -> DebugScript.fromCompiled(compiled)) + ) } /** Appends transaction building steps to this builder. diff --git a/scalus-core/shared/src/main/scala/scalus/uplc/DebugScript.scala b/scalus-core/shared/src/main/scala/scalus/uplc/DebugScript.scala new file mode 100644 index 000000000..95c6e9c34 --- /dev/null +++ b/scalus-core/shared/src/main/scala/scalus/uplc/DebugScript.scala @@ -0,0 +1,45 @@ +package scalus.uplc + +import scalus.cardano.ledger.PlutusScript + +/** A debug-compiled Plutus script for diagnostic replay. + * + * When a release script (compiled without error traces) fails during evaluation with empty logs, + * the evaluator can use a DebugScript to replay the failing evaluation with traces enabled, + * producing diagnostic logs. + * + * DebugScript wraps either: + * - A pre-compiled debug [[PlutusScript]] (for external builders like meshJS that have CBOR) + * - A lazy computation from [[CompiledPlutus]] (deferred SIR recompilation until failure) + * + * @param compute + * lazy computation that produces the debug PlutusScript + */ +class DebugScript(compute: () => PlutusScript) { + lazy val plutusScript: PlutusScript = compute() +} + +object DebugScript { + + /** Creates a DebugScript from a pre-compiled debug PlutusScript. + * + * Use this when you already have a debug-compiled script in CBOR form (e.g., from an external + * transaction builder). + * + * @param script + * the debug-compiled PlutusScript with traces enabled + */ + def apply(script: PlutusScript): DebugScript = + new DebugScript(() => script) + + /** Creates a DebugScript from a CompiledPlutus, deferring recompilation with error traces. + * + * The SIR recompilation to enable traces is lazy — it only happens if/when the release script + * fails and diagnostic replay is triggered. + * + * @param compiled + * the compiled Plutus script to recompile with error traces on failure + */ + def fromCompiled(compiled: CompiledPlutus[?]): DebugScript = + new DebugScript(() => compiled.withErrorTraces.script) +} From e2d8439075e481fc5c0a2db99311f5f3527fc6c8 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 17 Feb 2026 15:20:14 +0200 Subject: [PATCH 3/4] feat: strip trace/log statements for release builds Add RemoveTraces SIR transformer that strips fully-applied Trace builtin calls before lowering, replacing them with their value argument and cleaning up dead let bindings. Applied via new `removeTraces` option in Options (enabled by default in Options.release). The `withErrorTraces` method restores traces for diagnostic replay. --- .../main/scala/scalus/compiler/compiler.scala | 2 + .../scalus/compiler/sir/RemoveTraces.scala | 156 +++++++++++++++ .../compiler/sir/SIRDefaultOptions.scala | 1 + .../src/main/scala/scalus/uplc/Compiled.scala | 18 +- .../compiler/sir/RemoveTracesSpec.scala | 189 ++++++++++++++++++ 5 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala create mode 100644 scalus-core/shared/src/test/scala/scalus/compiler/sir/RemoveTracesSpec.scala diff --git a/scalus-core/shared/src/main/scala/scalus/compiler/compiler.scala b/scalus-core/shared/src/main/scala/scalus/compiler/compiler.scala index f93232e6b..b3d56bc71 100644 --- a/scalus-core/shared/src/main/scala/scalus/compiler/compiler.scala +++ b/scalus-core/shared/src/main/scala/scalus/compiler/compiler.scala @@ -9,6 +9,7 @@ case class Options( targetLoweringBackend: TargetLoweringBackend = SIRDefaultOptions.targetLoweringBackend, targetLanguage: Language = Language.PlutusV3, generateErrorTraces: Boolean = SIRDefaultOptions.generateErrorTraces, + removeTraces: Boolean = SIRDefaultOptions.removeTraces, optimizeUplc: Boolean = SIRDefaultOptions.optimizeUplc, debugLevel: Int = SIRDefaultOptions.debugLevel, debug: Boolean = false @@ -27,6 +28,7 @@ object Options { val release: Options = Options( targetLoweringBackend = TargetLoweringBackend.SirToUplcV3Lowering, generateErrorTraces = false, + removeTraces = true, optimizeUplc = true, debug = false ) diff --git a/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala b/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala new file mode 100644 index 000000000..38920e5cb --- /dev/null +++ b/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala @@ -0,0 +1,156 @@ +package scalus.compiler.sir + +import scalus.compiler.sir.SIR.Case +import scalus.uplc.{Constant, DefaultFun} + +/** Removes fully-applied `Trace` builtin calls from SIR, replacing them with their value argument. + * + * This is used for release builds to strip `log()` calls and their message computations (string + * concatenation, show calls, etc.) from the script, reducing size and execution cost. + * + * The transformation also cleans up dead let bindings that become trivial `()` after trace removal + * (the common pattern from `log("msg")` → `let _ = trace(msg)(()) in body` → `let _ = () in body` + * → `body`). + */ +object RemoveTraces { + + /** Transforms a SIR tree by removing all fully-applied Trace calls. + * + * @param sir + * the SIR tree to transform + * @return + * the transformed SIR with trace calls removed + */ + def transform(sir: SIR): SIR = sir match { + case a: AnnotatedSIR => transformAnnotated(a) + case SIR.Decl(data, term) => SIR.Decl(data, transform(term)) + } + + private def transformAnnotated(sir: AnnotatedSIR): AnnotatedSIR = sir match { + // Fully-applied Trace: Apply(Apply(Builtin(Trace), msg), value) → value + case SIR.Apply( + SIR.Apply(SIR.Builtin(DefaultFun.Trace, _, _), _, _, _), + value, + _, + _ + ) => + transformAnnotated(value) + + case SIR.Let(bindings, body, flags, anns) => + val newBindings = bindings.map(transformBinding) + val newBody = transform(body) + // Remove dead bindings: those whose RHS is Const(Unit) and whose name is unused + val (liveBindings, _) = + newBindings.foldRight((List.empty[Binding], newBody)) { + case (b, (acc, bodyAndTail)) => + val restSir = acc match { + case Nil => bodyAndTail + case _ => + SIR.Let(acc, bodyAndTail, flags, anns) + } + b.value match { + case SIR.Const(Constant.Unit, _, _) if !containsVar(restSir, b.name) => + (acc, bodyAndTail) + case _ => (b :: acc, bodyAndTail) + } + } + (liveBindings, newBody) match { + case (Nil, a: AnnotatedSIR) => a + case _ => SIR.Let(liveBindings, newBody, flags, anns) + } + + case SIR.LamAbs(param, term, typeParams, anns) => + SIR.LamAbs(param, transform(term), typeParams, anns) + + case SIR.Apply(f, arg, tp, anns) => + SIR.Apply(transformAnnotated(f), transformAnnotated(arg), tp, anns) + + case SIR.And(a, b, anns) => + SIR.And(transformAnnotated(a), transformAnnotated(b), anns) + + case SIR.Or(a, b, anns) => + SIR.Or(transformAnnotated(a), transformAnnotated(b), anns) + + case SIR.Not(a, anns) => + SIR.Not(transformAnnotated(a), anns) + + case SIR.IfThenElse(cond, t, f, tp, anns) => + SIR.IfThenElse( + transformAnnotated(cond), + transformAnnotated(t), + transformAnnotated(f), + tp, + anns + ) + + case SIR.Error(msg, anns, cause) => + SIR.Error(transformAnnotated(msg), anns, cause) + + case SIR.Constr(name, data, args, tp, anns) => + SIR.Constr(name, data, args.map(transform), tp, anns) + + case SIR.Match(scrutinee, cases, tp, anns) => + SIR.Match(transformAnnotated(scrutinee), cases.map(transformCase), tp, anns) + + case SIR.Select(scrutinee, field, tp, anns) => + SIR.Select(transform(scrutinee), field, tp, anns) + + case SIR.Cast(term, tp, anns) => + SIR.Cast(transformAnnotated(term), tp, anns) + + case _: SIR.Var | _: SIR.ExternalVar | _: SIR.Const | _: SIR.Builtin => sir + } + + private def transformBinding(binding: Binding): Binding = + binding.copy(value = transform(binding.value)) + + private def transformCase(cse: Case): Case = + cse.copy(body = transform(cse.body)) + + /** Checks whether a variable name appears free in a SIR tree. */ + private def containsVar(sir: SIR, name: String): Boolean = sir match { + case SIR.Decl(_, term) => containsVar(term, name) + case a: AnnotatedSIR => containsVarAnnotated(a, name) + } + + private def containsVarAnnotated(sir: AnnotatedSIR, name: String): Boolean = sir match { + case SIR.Var(n, _, _) => n == name + case SIR.ExternalVar(_, n, _, _) => n == name + case SIR.Let(bindings, body, flags, _) => + val (found, shadowed) = bindings.foldLeft((false, false)) { + case ((true, _), _) => (true, true) // already found + case ((false, shadowed), b) => + if !shadowed && containsVar(b.value, name) then (true, true) + else if b.name == name then (false, true) // shadowed from here on + else (false, shadowed) + } + found || (!shadowed && containsVar(body, name)) + case SIR.LamAbs(param, term, _, _) => + if param.name == name then false else containsVar(term, name) + case SIR.Apply(f, arg, _, _) => + containsVarAnnotated(f, name) || containsVarAnnotated(arg, name) + case SIR.And(a, b, _) => + containsVarAnnotated(a, name) || containsVarAnnotated(b, name) + case SIR.Or(a, b, _) => + containsVarAnnotated(a, name) || containsVarAnnotated(b, name) + case SIR.Not(a, _) => containsVarAnnotated(a, name) + case SIR.IfThenElse(cond, t, f, _, _) => + containsVarAnnotated(cond, name) || + containsVarAnnotated(t, name) || + containsVarAnnotated(f, name) + case SIR.Error(msg, _, _) => containsVarAnnotated(msg, name) + case SIR.Constr(_, _, args, _, _) => args.exists(containsVar(_, name)) + case SIR.Match(scrutinee, cases, _, _) => + containsVarAnnotated(scrutinee, name) || + cases.exists { c => + val shadowed = c.pattern match { + case SIR.Pattern.Constr(_, bindings, _) => bindings.contains(name) + case _ => false + } + !shadowed && containsVar(c.body, name) + } + case SIR.Select(scrutinee, _, _, _) => containsVar(scrutinee, name) + case SIR.Cast(term, _, _) => containsVarAnnotated(term, name) + case _: SIR.Const | _: SIR.Builtin => false + } +} diff --git a/scalus-core/shared/src/main/scala/scalus/compiler/sir/SIRDefaultOptions.scala b/scalus-core/shared/src/main/scala/scalus/compiler/sir/SIRDefaultOptions.scala index a6a3cd5ce..006a65cb7 100644 --- a/scalus-core/shared/src/main/scala/scalus/compiler/sir/SIRDefaultOptions.scala +++ b/scalus-core/shared/src/main/scala/scalus/compiler/sir/SIRDefaultOptions.scala @@ -18,6 +18,7 @@ object SIRDefaultOptions { val targetLoweringBackend: TargetLoweringBackend = TargetLoweringBackend.SirToUplcV3Lowering val generateErrorTraces: Boolean = true + val removeTraces: Boolean = false val optimizeUplc: Boolean = false // debugging options diff --git a/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala b/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala index 66f4140d1..095e58cee 100644 --- a/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala +++ b/scalus-core/shared/src/main/scala/scalus/uplc/Compiled.scala @@ -5,7 +5,7 @@ import scalus.cardano.ledger.{Credential, Language, PlutusScript, Script} import scalus.compiler import scalus.compiler.sir.lowering.SirToUplcV3Lowering import scalus.compiler.sir.lowering.simple.{ScottEncodingLowering, SumOfProductsLowering} -import scalus.compiler.sir.{AnnotationsDecl, SIR, SIRType, TargetLoweringBackend} +import scalus.compiler.sir.{AnnotationsDecl, RemoveTraces, SIR, SIRType, TargetLoweringBackend} import scalus.compiler.{compileInlineWithOptions, Options} import scalus.uplc.Constant.asConstant import scalus.uplc.transform.* @@ -110,15 +110,16 @@ sealed abstract class CompiledPlutus[A]( /** Lowers the SIR to UPLC using the configured backend and applies optimization if enabled. */ protected def toUplc: Term = { + val sirToLower = if options.removeTraces then RemoveTraces.transform(sir) else sir val backend = options.targetLoweringBackend val uplc = backend match case TargetLoweringBackend.ScottEncodingLowering => - ScottEncodingLowering(sir, options.generateErrorTraces).lower() + ScottEncodingLowering(sirToLower, options.generateErrorTraces).lower() case TargetLoweringBackend.SumOfProductsLowering => - SumOfProductsLowering(sir, options.generateErrorTraces).lower() + SumOfProductsLowering(sirToLower, options.generateErrorTraces).lower() case TargetLoweringBackend.SirToUplcV3Lowering => SirToUplcV3Lowering( - sir, + sirToLower, generateErrorTraces = options.generateErrorTraces, debug = options.debug, targetLanguage = language @@ -178,7 +179,8 @@ final case class PlutusV1[A] private[uplc] ( * @return * a new [[PlutusV1]] with `generateErrorTraces = true` */ - def withErrorTraces: PlutusV1[A] = copy(options = options.copy(generateErrorTraces = true)) + def withErrorTraces: PlutusV1[A] = + copy(options = options.copy(generateErrorTraces = true, removeTraces = false)) } /** Factory methods for creating compiled Plutus V1 scripts. */ @@ -293,7 +295,8 @@ final case class PlutusV2[A] private[uplc] ( * @return * a new [[PlutusV2]] with `generateErrorTraces = true` */ - def withErrorTraces: PlutusV2[A] = copy(options = options.copy(generateErrorTraces = true)) + def withErrorTraces: PlutusV2[A] = + copy(options = options.copy(generateErrorTraces = true, removeTraces = false)) } /** Factory methods for creating compiled Plutus V2 scripts. */ @@ -413,7 +416,8 @@ final case class PlutusV3[A] private[uplc] ( * @return * a new [[PlutusV3]] with `generateErrorTraces = true` */ - def withErrorTraces: PlutusV3[A] = copy(options = options.copy(generateErrorTraces = true)) + def withErrorTraces: PlutusV3[A] = + copy(options = options.copy(generateErrorTraces = true, removeTraces = false)) } /** Factory methods for creating compiled Plutus V3 scripts. */ diff --git a/scalus-core/shared/src/test/scala/scalus/compiler/sir/RemoveTracesSpec.scala b/scalus-core/shared/src/test/scala/scalus/compiler/sir/RemoveTracesSpec.scala new file mode 100644 index 000000000..cc9ec33f0 --- /dev/null +++ b/scalus-core/shared/src/test/scala/scalus/compiler/sir/RemoveTracesSpec.scala @@ -0,0 +1,189 @@ +package scalus.compiler.sir + +import org.scalatest.funsuite.AnyFunSuite +import scalus.uplc.DefaultFun + +class RemoveTracesSpec extends AnyFunSuite { + + private val anns = AnnotationsDecl.empty + + private def traceCall(msg: AnnotatedSIR, value: AnnotatedSIR): SIR.Apply = { + val traceBuiltin = SIR.Builtin( + DefaultFun.Trace, + SIRType.Fun(SIRType.String, SIRType.Fun(SIRType.Unit, SIRType.Unit)), + anns + ) + val partial = SIR.Apply(traceBuiltin, msg, SIRType.Fun(SIRType.Unit, SIRType.Unit), anns) + SIR.Apply(partial, value, SIRType.Unit, anns) + } + + private val unitConst = SIR.Const.unit(anns) + private val intConst = SIR.Const.integer(42, anns) + private val strConst = SIR.Const.string("hello", anns) + + test("fully applied Trace is replaced by its value argument") { + val trace = traceCall(strConst, unitConst) + val result = RemoveTraces.transform(trace) + assert(result == unitConst) + } + + test("Trace with non-unit value preserves the value") { + val trace = traceCall(strConst, intConst) + val result = RemoveTraces.transform(trace) + assert(result == intConst) + } + + test("message subtree with appendString is dropped") { + val appendStr = SIR.Builtin( + DefaultFun.AppendString, + SIRType.Fun(SIRType.String, SIRType.Fun(SIRType.String, SIRType.String)), + anns + ) + val concat = SIR.Apply( + SIR.Apply( + appendStr, + SIR.Const.string("prefix: ", anns), + SIRType.Fun(SIRType.String, SIRType.String), + anns + ), + SIR.Const.string("value", anns), + SIRType.String, + anns + ) + val trace = traceCall(concat, intConst) + val result = RemoveTraces.transform(trace) + assert(result == intConst) + } + + test("non-trace nodes are preserved unchanged") { + val add = SIR.Apply( + SIR.Apply( + SIR.Builtin( + DefaultFun.AddInteger, + SIRType.Fun(SIRType.Integer, SIRType.Fun(SIRType.Integer, SIRType.Integer)), + anns + ), + intConst, + SIRType.Fun(SIRType.Integer, SIRType.Integer), + anns + ), + SIR.Const.integer(1, anns), + SIRType.Integer, + anns + ) + val result = RemoveTraces.transform(add) + assert(result == add) + } + + test("nested traces are handled") { + // trace(msg1)(trace(msg2)(value)) + val inner = traceCall(SIR.Const.string("inner", anns), intConst) + val outer = traceCall(SIR.Const.string("outer", anns), inner) + val result = RemoveTraces.transform(outer) + assert(result == intConst) + } + + test("trace inside let binding is removed") { + val trace = traceCall(strConst, unitConst) + val body = intConst + val let = SIR.Let( + List(Binding("_log", SIRType.Unit, trace)), + body, + SIR.LetFlags.None, + anns + ) + val result = RemoveTraces.transform(let) + // The binding becomes Const(Unit) and _log is unused in body → binding removed + assert(result == intConst) + } + + test("trace inside lambda is removed") { + val trace = traceCall(strConst, unitConst) + val param = SIR.Var("x", SIRType.Integer, anns) + val body = SIR.Let( + List(Binding("_log", SIRType.Unit, trace)), + param, + SIR.LetFlags.None, + anns + ) + val lam = SIR.LamAbs(param, body, Nil, anns) + val result = RemoveTraces.transform(lam) + // After transform: LamAbs(x, x) with the dead let removed + val expected = SIR.LamAbs(param, param, Nil, anns) + assert(result == expected) + } + + test("trace inside match case body is removed") { + val trace = traceCall(strConst, unitConst) + val body = SIR.Let( + List(Binding("_log", SIRType.Unit, trace)), + intConst, + SIR.LetFlags.None, + anns + ) + val caseExpr = SIR.Case(SIR.Pattern.Wildcard, body, anns) + val scrutinee = SIR.Const.boolean(true, anns) + val matchExpr = SIR.Match(scrutinee, List(caseExpr), SIRType.Integer, anns) + val result = RemoveTraces.transform(matchExpr) + val expectedCase = SIR.Case(SIR.Pattern.Wildcard, intConst, anns) + val expected = SIR.Match(scrutinee, List(expectedCase), SIRType.Integer, anns) + assert(result == expected) + } + + test("dead let binding cleanup does not remove used bindings") { + val xVar = SIR.Var("x", SIRType.Integer, anns) + val let = SIR.Let( + List(Binding("x", SIRType.Integer, intConst)), + xVar, + SIR.LetFlags.None, + anns + ) + val result = RemoveTraces.transform(let) + assert(result == let) // binding is used, preserved + } + + test("dead let binding cleanup removes only Unit bindings that are unused") { + val xVar = SIR.Var("x", SIRType.Integer, anns) + val trace = traceCall(strConst, unitConst) + val let = SIR.Let( + List( + Binding("_log", SIRType.Unit, trace), + Binding("x", SIRType.Integer, intConst) + ), + xVar, + SIR.LetFlags.None, + anns + ) + val result = RemoveTraces.transform(let) + // _log binding removed (Unit, unused), x binding preserved (used) + val expected = SIR.Let( + List(Binding("x", SIRType.Integer, intConst)), + xVar, + SIR.LetFlags.None, + anns + ) + assert(result == expected) + } + + test("trace inside IfThenElse branches is removed") { + val trace = traceCall(SIR.Const.string("then", anns), intConst) + val cond = SIR.Const.boolean(true, anns) + val ite = SIR.IfThenElse(cond, trace, intConst, SIRType.Integer, anns) + val result = RemoveTraces.transform(ite) + val expected = SIR.IfThenElse(cond, intConst, intConst, SIRType.Integer, anns) + assert(result == expected) + } + + test("partially applied Trace is not removed") { + val traceBuiltin = SIR.Builtin( + DefaultFun.Trace, + SIRType.Fun(SIRType.String, SIRType.Fun(SIRType.Unit, SIRType.Unit)), + anns + ) + // Only one Apply - partially applied + val partial = + SIR.Apply(traceBuiltin, strConst, SIRType.Fun(SIRType.Unit, SIRType.Unit), anns) + val result = RemoveTraces.transform(partial) + assert(result == partial) // preserved as-is + } +} From ec07ef87dcb778d6396336b7222fcf2c339fb89a Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 17 Feb 2026 16:51:35 +0200 Subject: [PATCH 4/4] fix: address PR review comments for diagnostic replay - Reorder ScriptFailure params to keep `logs` second for backwards compat - Add console.error/warn logging in JEmulator for failed script resolution and unmatched debug script hashes - Improve diagnostic replay error messages with exception class names - Add scribe log.warn/error for unexpected replay outcomes - Document conservative dead-binding elimination in RemoveTraces - Clarify Options.release behavior in docs (both traces and error traces omitted) - Add note about CompiledPlutus vs DebugScript API for external builders - Add migration note to withDebugScript scaladoc - Add assertion that release/debug script hashes differ in test --- .../main/scala/scalus/cardano/node/JEmulator.scala | 14 +++++++++++--- .../cardano/txbuilder/DiagnosticReplayTest.scala | 7 ++++++- .../cardano/ledger/PlutusScriptEvaluator.scala | 14 ++++++++++++-- .../scalus/cardano/node/BlockchainProvider.scala | 6 +++--- .../scala/scalus/cardano/txbuilder/TxBuilder.scala | 4 ++++ .../scala/scalus/compiler/sir/RemoveTraces.scala | 5 ++++- scalus-site/content/testing/debugging.mdx | 8 ++++++-- 7 files changed, 46 insertions(+), 12 deletions(-) diff --git a/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala b/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala index b0046a027..d15247835 100644 --- a/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala +++ b/scalus-cardano-ledger/js/src/main/scala/scalus/cardano/node/JEmulator.scala @@ -64,8 +64,12 @@ class JEmulator( // Resolve scripts from the transaction to determine language versions val resolvedScripts = AllResolvedScripts.allResolvedScriptsMap(tx, emulator.utxos) match - case Right(map) => map - case Left(error) => Map.empty[ScriptHash, Script] + case Right(map) => map + case Left(error) => + js.Dynamic.global.console.error( + s"Emulator.submitTx(debugScripts): failed to resolve scripts: $error" + ) + Map.empty[ScriptHash, Script] // Parse debug scripts dictionary val debugScriptsMap: Map[ScriptHash, DebugScript] = debugScripts.flatMap { @@ -76,6 +80,10 @@ class JEmulator( val languageOpt = resolvedScripts.get(hash).collect { case ps: PlutusScript => ps.language } + if languageOpt.isEmpty then + js.Dynamic.global.console.warn( + s"Debug script for hash $hashHex was provided but no matching Plutus script was found in the transaction." + ) languageOpt.map { language => val plutusScript: PlutusScript = language match case Language.PlutusV1 => Script.PlutusV1(doubleCbor) @@ -95,7 +103,7 @@ class JEmulator( js.Dynamic.literal(isSuccess = true, txHash = txHash.toHex) case Left(submitError) => submitError match { - case NodeSubmitError.ScriptFailure(msg, _, logs) if logs.nonEmpty => + case NodeSubmitError.ScriptFailure(msg, logs, _) if logs.nonEmpty => js.Dynamic.literal( isSuccess = false, error = msg, diff --git a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala index ec6abdd68..5b61e579a 100644 --- a/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala +++ b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala @@ -214,6 +214,11 @@ class DiagnosticReplayTest extends AnyFunSuite { // Options differ only in generateErrorTraces assert(withTraces.options.generateErrorTraces) assert(!release.options.generateErrorTraces) + // Script hashes are different because the UPLC bytecode differs + assert( + release.script.scriptHash != withTraces.script.scriptHash, + "Debug script should have a different hash than the release script" + ) } test("DebugScript from raw PlutusScript via TxBuilder withDebugScript") { @@ -283,7 +288,7 @@ class DiagnosticReplayTest extends AnyFunSuite { assert(result.isLeft, "Transaction should fail") result.left.foreach { - case NodeSubmitError.ScriptFailure(_, _, logs) => + case NodeSubmitError.ScriptFailure(_, logs, _) => assert( logs.nonEmpty, "Emulator submitSync with debug scripts should produce diagnostic logs" diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala index ddee5e12f..445f47f0c 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/ledger/PlutusScriptEvaluator.scala @@ -659,10 +659,20 @@ object PlutusScriptEvaluator { catch case NonFatal(_) => replayFailed = true val logs = replayLogger.getLogs if replayFailed then logs - else logs :+ "[diagnostic replay: debug script succeeded unexpectedly]" + else + log.warn( + s"Diagnostic replay: debug script succeeded unexpectedly for script hash $hash" + ) + logs :+ "[diagnostic replay: debug script succeeded unexpectedly]" catch case NonFatal(e) => - Array(s"[diagnostic replay failed: ${e.getMessage}]") + log.error( + s"Diagnostic replay failed for script hash $hash", + e + ) + Array( + s"[diagnostic replay failed: ${e.getClass.getName}: ${e.getMessage}]" + ) } /** Dump script information for debugging purposes. diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala index 1bf3b3ce7..977590509 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/node/BlockchainProvider.scala @@ -365,8 +365,8 @@ object NodeSubmitError { /** Script execution failures */ case class ScriptFailure( message: String, - scriptHash: Option[ScriptHash] = None, - logs: Seq[String] = Seq.empty + logs: Seq[String] = Seq.empty, + scriptHash: Option[ScriptHash] = None ) extends NodeSubmitError /** Other node validation errors (catch-all for unrecognized validation errors) */ @@ -509,7 +509,7 @@ object SubmitError { case e: TransactionException.NativeScriptsException => ScriptFailure(e.explain) case e: TransactionException.PlutusScriptValidationException => - ScriptFailure(e.explain, scriptHash = e.scriptHash, logs = e.logs) + ScriptFailure(e.explain, logs = e.logs, scriptHash = e.scriptHash) case e => ValidationError(e.explain) } diff --git a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala index 131b798c4..66a1fc6b2 100644 --- a/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala +++ b/scalus-cardano-ledger/shared/src/main/scala/scalus/cardano/txbuilder/TxBuilder.scala @@ -1918,6 +1918,10 @@ case class TxBuilder( * [[CompiledPlutus]]. Use this method directly for reference-script use cases where the script * is not attached to the transaction but you still want diagnostic replay. * + * '''Migration note:''' If you previously used `validator.script` (a `PlutusScript`) with + * `spend` or `mint`, pass `validator` (a `CompiledPlutus`) directly instead to enable + * automatic diagnostic replay. + * * @param compiled * the compiled Plutus script to register for diagnostic replay */ diff --git a/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala b/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala index 38920e5cb..8b4be48b0 100644 --- a/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala +++ b/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala @@ -39,7 +39,10 @@ object RemoveTraces { case SIR.Let(bindings, body, flags, anns) => val newBindings = bindings.map(transformBinding) val newBody = transform(body) - // Remove dead bindings: those whose RHS is Const(Unit) and whose name is unused + // Remove dead bindings: those whose RHS is Const(Unit) and whose name is unused. + // This is conservative — only targets the common `log("msg")` pattern where + // trace removal leaves `let _ = () in body`. Other trivial pure expressions + // are kept as-is. val (liveBindings, _) = newBindings.foldRight((List.empty[Binding], newBody)) { case (b, (acc, bodyAndTail)) => diff --git a/scalus-site/content/testing/debugging.mdx b/scalus-site/content/testing/debugging.mdx index b237dceae..08f7149be 100644 --- a/scalus-site/content/testing/debugging.mdx +++ b/scalus-site/content/testing/debugging.mdx @@ -154,15 +154,19 @@ val result = compiled.toUplc(generateErrorTraces = true).evaluateDebug ## Diagnostic Replay for Release Scripts -When deploying to production, you typically compile scripts with `Options.release` (no error traces) to minimize script size and execution costs. However, if a release script fails, the error logs will be empty — making it hard to diagnose the issue. +When deploying to production, you typically compile scripts with `Options.release` (which sets `removeTraces = true` and `generateErrorTraces = false`) to minimize script size and execution costs. This means both trace logs and detailed error traces (e.g., `require` messages) are omitted in release scripts, so if a release script fails, you will not see useful error information in the logs — making it hard to diagnose the issue. -**Diagnostic replay** solves this: when you use `CompiledPlutus` (e.g., `PlutusV3.compile(...)`) with `TxBuilder`, the builder automatically registers the compiled script for replay. If the release script fails with empty logs, the evaluator: +**Diagnostic replay** solves this: when you use `CompiledPlutus` (e.g., `PlutusV3.compile(...)`) with `TxBuilder`, the builder automatically registers the compiled script for replay. The `CompiledPlutus` object retains the SIR, so it can recompile a debug version on demand. If the release script fails with empty logs, the evaluator: 1. Recompiles the script from SIR with error traces enabled 2. Replays the failing evaluation with the same arguments 3. Collects the diagnostic logs from the replay 4. Includes them in the `PlutusScriptEvaluationException` +> **Note:** Automatic replay requires `CompiledPlutus` (which keeps the SIR). If you use an external +> tx builder (e.g., meshJS or Bloxbean CCL), you won't have a `CompiledPlutus` object — instead, +> use the `DebugScript` API to provide a pre-compiled debug script. See [DebugScript API](#debugscript-api-for-external-builders) below. + ### Using Diagnostic Replay with TxBuilder Pass `CompiledPlutus` (the result of `PlutusV3.compile(...)`) instead of `PlutusScript` to `spend` or `mint`: