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..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 @@ -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,12 +43,67 @@ 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) => + 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 { + 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 + } + 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) + 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) => 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, @@ -55,7 +113,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 new file mode 100644 index 000000000..5b61e579a --- /dev/null +++ b/scalus-cardano-ledger/jvm/src/test/scala/scalus/cardano/txbuilder/DiagnosticReplayTest.scala @@ -0,0 +1,304 @@ +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 + +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) + // 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") { + 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 4cb3c2029..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 @@ -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.DebugScript] ): 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/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 48828fc61..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 @@ -2,6 +2,7 @@ package scalus.cardano.ledger import scalus.uplc.builtin.Data.toData import scalus.uplc.builtin.{platform, Data} +import scalus.uplc.DebugScript 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, DebugScript] = Map.empty + ): Seq[Redeemer] = evalPlutusScriptsWithContexts(tx, utxos, debugScripts).map(_._1) def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, + debugScripts: Map[ScriptHash, DebugScript] = 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, DebugScript] ): Seq[(Redeemer, ScriptContext, ScriptHash)] = Seq.empty } @@ -205,6 +210,7 @@ object PlutusScriptEvaluator { txhash: String, vm: PlutusVM, plutusScript: PlutusScript, + debugScripts: Map[ScriptHash, DebugScript], args: Data* ): Result = { Result.Success( @@ -319,6 +325,7 @@ object PlutusScriptEvaluator { override def evalPlutusScriptsWithContexts( tx: Transaction, utxos: Utxos, + debugScripts: Map[ScriptHash, DebugScript] = 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, DebugScript] ): (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, DebugScript] ): (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, DebugScript] ): (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, DebugScript], args: Data* ): Result = { // Parse UPLC program from CBOR @@ -580,18 +600,79 @@ 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, DebugScript], + hash: ScriptHash, + args: Seq[Data] + ): Array[String] = { + debugScripts.get(hash) match + case None => Array.empty + case Some(ds) => + try + val debugScript = ds.plutusScript + 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 + log.warn( + s"Diagnostic replay: debug script succeeded unexpectedly for script hash $hash" + ) + logs :+ "[diagnostic replay: debug script succeeded unexpectedly]" + catch + case NonFatal(e) => + 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/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..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, 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/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 ee4c37169..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,6 +9,7 @@ package scalus.cardano.txbuilder import io.bullet.borer.Encoder import monocle.syntax.all.* import monocle.{Focus, Lens} +import scalus.uplc.DebugScript 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, DebugScript] = 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, DebugScript] = 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, DebugScript] = 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, DebugScript] = 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, DebugScript] = 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, DebugScript] = 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..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 @@ -1,5 +1,6 @@ package scalus.cardano.txbuilder +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} @@ -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, DebugScript] = 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,52 @@ 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 + * 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. + * + * '''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 + */ + def withDebugScript(compiled: CompiledPlutus[?]): TxBuilder = { + registerDebugScript(compiled) + } + + private def registerDebugScript(compiled: CompiledPlutus[?]): TxBuilder = { + copy(debugScripts = + debugScripts + (compiled.script.scriptHash -> DebugScript.fromCompiled(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/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..8b4be48b0 --- /dev/null +++ b/scalus-core/shared/src/main/scala/scalus/compiler/sir/RemoveTraces.scala @@ -0,0 +1,159 @@ +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. + // 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)) => + 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 0d4094645..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.* @@ -98,17 +98,28 @@ 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 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 @@ -168,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. */ @@ -283,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. */ @@ -403,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/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) +} 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 + } +} diff --git a/scalus-site/content/testing/debugging.mdx b/scalus-site/content/testing/debugging.mdx index 732ef969e..08f7149be 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,61 @@ 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` (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. 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`: + +```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: