From 18465c1432a0146bb0d2bbb92036d319ae32a5ca Mon Sep 17 00:00:00 2001 From: Satya Date: Fri, 29 May 2026 18:23:13 +0800 Subject: [PATCH 1/2] fix(julc-vm-scalus): support V3 custom cost models on Scalus 0.17 Upgrade Scalus to 0.17.0 and adapt the Scalus VM integration to its updated API. Key PlutusV3 custom cost models by the integer language id expected by MachineParams.fromCostModels, fixing the runtime "key not found: 2" failure when using chain-supplied V3 cost model params. Pass the new evaluateDeBruijnedTerm tracing argument explicitly as false to preserve the previous non-tracing evaluation behavior --- julc-cardano-client-lib/build.gradle | 4 ++-- julc-vm-scalus/build.gradle | 2 +- .../julc/vm/scalus/ScalusVmProvider.java | 20 +++++++++++-------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/julc-cardano-client-lib/build.gradle b/julc-cardano-client-lib/build.gradle index 5b289993..339da22b 100644 --- a/julc-cardano-client-lib/build.gradle +++ b/julc-cardano-client-lib/build.gradle @@ -10,11 +10,11 @@ dependencies { implementation project(':julc-ledger-api') implementation project(':julc-stdlib') implementation project(':julc-vm') - implementation 'com.bloxbean.cardano:cardano-client-lib:0.7.1' + implementation 'com.bloxbean.cardano:cardano-client-lib:0.7.2' testImplementation project(':julc-vm-java') testRuntimeOnly project(':julc-vm-scalus') - testImplementation 'org.scalus:scalus-bloxbean-cardano-client-lib_3:0.16.0' + testImplementation 'org.scalus:scalus-bloxbean-cardano-client-lib_3:0.17.0' } publishing { diff --git a/julc-vm-scalus/build.gradle b/julc-vm-scalus/build.gradle index 97cdc314..74631c85 100644 --- a/julc-vm-scalus/build.gradle +++ b/julc-vm-scalus/build.gradle @@ -15,7 +15,7 @@ sourceSets { dependencies { api project(':julc-vm') - implementation 'org.scalus:scalus_3:0.16.0' + implementation 'org.scalus:scalus_3:0.17.0' } publishing { diff --git a/julc-vm-scalus/src/main/java/com/bloxbean/cardano/julc/vm/scalus/ScalusVmProvider.java b/julc-vm-scalus/src/main/java/com/bloxbean/cardano/julc/vm/scalus/ScalusVmProvider.java index d207704b..c94be4d7 100644 --- a/julc-vm-scalus/src/main/java/com/bloxbean/cardano/julc/vm/scalus/ScalusVmProvider.java +++ b/julc-vm-scalus/src/main/java/com/bloxbean/cardano/julc/vm/scalus/ScalusVmProvider.java @@ -1,6 +1,5 @@ package com.bloxbean.cardano.julc.vm.scalus; -import com.bloxbean.cardano.julc.core.Constant; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.core.Program; import com.bloxbean.cardano.julc.core.Term; @@ -41,10 +40,11 @@ public class ScalusVmProvider implements JulcVmProvider { @Override public void setCostModelParams(long[] costModelValues, PlutusLanguage language, int protocolMajorVersion, int protocolMinorVersion) { - // Map protocol major version to Scalus MajorProtocolVersion - MajorProtocolVersion pv = protocolMajorVersion >= 10 - ? MajorProtocolVersion.plominPV() - : MajorProtocolVersion.changPV(); + // Map the chain's protocol major version DIRECTLY to Scalus's MajorProtocolVersion + // (9 -> changPV, 10 -> plominPV, 11 -> vanRossemPV, and future versions). Do NOT collapse + // 11+ to plominPV: PlutusVM gates PV11 semantics (e.g. case-on-builtins) on + // protocolVersion >= vanRossemPV, so feeding the wrong version silently disables them. + MajorProtocolVersion pv = new MajorProtocolVersion(protocolMajorVersion); this.protocolVersion = pv; // Only V3 supports custom MachineParams in Scalus; V1/V2 use built-in defaults @@ -59,10 +59,12 @@ public void setCostModelParams(long[] costModelValues, PlutusLanguage language, } scala.collection.immutable.IndexedSeq indexedSeq = builder.result(); - // Build CostModels map: Language.PlutusV3 -> indexedSeq + // Build CostModels map: the key MUST be the integer languageId (Language.PlutusV3.languageId() == 2, + // which equals the enum ordinal). MachineParams.fromCostModels() looks the model up via + // boxToInteger(language.ordinal()), so keying by the Language enum object caused "key not found: 2". scala.collection.immutable.Map> map = scala.collection.immutable.Map$.MODULE$.>empty() - .updated(Language.PlutusV3, indexedSeq); + .updated(Language.PlutusV3.languageId(), indexedSeq); CostModels costModels = new CostModels(map); this.machineParams = MachineParams.fromCostModels(costModels, Language.PlutusV3, pv); @@ -128,8 +130,10 @@ private EvalResult evaluateScalusTerm(scalus.uplc.Term scalusTerm, PlutusLanguag // Evaluate using evaluateDeBruijnedTerm (general evaluation, // does not enforce script return-value semantics like evaluateScriptDebug) + // Scalus 0.17.0 added a 4th 'tracing' boolean (default false); pass false to preserve + // the prior 3-arg behaviour (no script return-value enforcement / tracing). scalus.uplc.Term scalusResult = vm.evaluateDeBruijnedTerm( - scalusTerm, budgetSpender, logger); + scalusTerm, budgetSpender, logger, false); // Convert result Term resultTerm = TermConverter.fromScalus(scalusResult); From dea4633f1f88cc441132e7e8ace7d926b0aadf5b Mon Sep 17 00:00:00 2001 From: Satya Date: Fri, 29 May 2026 20:54:14 +0800 Subject: [PATCH 2/2] fix(julc-cardano-client-lib): use single VM provider instance for cost-model setup and evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JulcTransactionEvaluator configured the chain cost model on one provider instance but evaluated on another. setCostModelParams ran on the instance created by getOrCreateVm()/JulcVm.create() (its own ServiceLoader lookup), while evaluation ran on JulcVm.withProvider(getProvider(), ...) — a separate ServiceLoader lookup yielding a different instance. Cost-model params are stored per provider instance, so the evaluating VM never received the chain cost model and silently fell back to its built-in default. This over-estimated the script budget on any protocol version whose cost model differs from the built-in default (e.g. PV11: 65,563,707 vs the correct 65,559,542 CPU). It was masked on PV10 because the default happened to match. Since QuickTx bakes the evaluator's ExUnits into the transaction, every tx built through this evaluator carried the wrong budget. Affected all backends (Java, Truffle, Scalus). Resolve the VM provider once via getProvider() and use that same instance for both setCostModelParams and evaluateWithArgs, passing the resolved script's language explicitly. Remove the now-unused vm field, getOrCreateVm(), and the JulcVm import. Document the evaluator's (unchanged) non-thread-safe contract. Add a regression test that supplies two differing PlutusV3 cost models and asserts the evaluated CPU budget tracks the supplied model rather than the built-in default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eval/JulcTransactionEvaluator.java | 39 ++++---- .../eval/JulcTransactionEvaluatorTest.java | 89 +++++++++++++++++++ 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/julc-cardano-client-lib/src/main/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluator.java b/julc-cardano-client-lib/src/main/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluator.java index a63b0076..f9327d6b 100644 --- a/julc-cardano-client-lib/src/main/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluator.java +++ b/julc-cardano-client-lib/src/main/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluator.java @@ -19,7 +19,6 @@ import com.bloxbean.cardano.julc.ledger.*; import com.bloxbean.cardano.julc.vm.EvalResult; import com.bloxbean.cardano.julc.vm.ExBudget; -import com.bloxbean.cardano.julc.vm.JulcVm; import com.bloxbean.cardano.julc.vm.PlutusLanguage; import com.bloxbean.cardano.julc.vm.trace.ExecutionTraceEntry; import com.bloxbean.cardano.julc.vm.trace.FailureReportBuilder; @@ -42,6 +41,15 @@ * .withSigner(signer) * .complete(); * } + * + *

Thread-safety: an instance is not thread-safe. Each {@link #evaluateTx} call + * (re)configures the cost model on a cached VM provider and then evaluates on it; the registered + * source maps/programs and trace flags are also mutable instance state. Concurrent use of a single + * instance can therefore race (e.g. one call reconfiguring the provider's cost model while another + * is evaluating). Create a new {@code JulcTransactionEvaluator} per transaction / evaluation + * context (or confine one instance to a single thread); do not share an instance across threads. + * Note: this warning is about this evaluator's per-call reconfiguration and mutable state. + * Do not rely on a cached provider inside this evaluator for concurrent evaluateTx calls. */ public class JulcTransactionEvaluator implements TransactionEvaluator { @@ -50,7 +58,6 @@ public class JulcTransactionEvaluator implements TransactionEvaluator { private final ScriptSupplier scriptSupplier; private final SlotConfig slotConfig; - private volatile JulcVm vm; private volatile com.bloxbean.cardano.julc.vm.JulcVmProvider provider; // Source map support @@ -206,12 +213,15 @@ public Result> evaluateTx(byte[] cbor, Set inputUtx Long.parseLong(params.getMaxTxExSteps()), Long.parseLong(params.getMaxTxExMem())); - // 6. Create VM (lazy, cached) and configure cost models for all language versions - JulcVm julcVm = getOrCreateVm(); + // 6. Resolve the VM provider (lazy, cached) and configure cost models for all language + // versions. The SAME provider instance must be used for setCostModelParams AND + // evaluation: cost-model params are stored per provider instance, so configuring one + // instance and evaluating on another would silently fall back to the built-in defaults. + var vmProvider = getProvider(); int pvMinor = params.getProtocolMinorVer() != null ? params.getProtocolMinorVer() : 0; for (var lang : List.of(Language.PLUTUS_V1, Language.PLUTUS_V2, Language.PLUTUS_V3)) { CostModelUtil.getCostModelFromProtocolParams(params, lang) - .ifPresent(cm -> julcVm.setCostModelParams( + .ifPresent(cm -> vmProvider.setCostModelParams( cm.getCosts(), toPlutusLanguage(lang), pvMajor, pvMinor)); } @@ -261,10 +271,10 @@ public Result> evaluateTx(byte[] cbor, Set inputUtx Program evalProgram = scriptPrograms.containsKey(scriptHash) ? scriptPrograms.get(scriptHash) : resolved.program(); - // e. Evaluate — traces are captured in the EvalResult - var langVm = JulcVm.withProvider(getProvider(), resolved.language()); - EvalResult evalResult = langVm.evaluateWithArgs( - evalProgram, args, maxBudget, evalOptions); + // e. Evaluate on the SAME provider configured above (not a fresh lookup), passing + // the resolved script's language explicitly. + EvalResult evalResult = vmProvider.evaluateWithArgs( + evalProgram, resolved.language(), args, maxBudget, evalOptions); var executionTrace = evalResult.executionTrace(); var builtinTrace = evalResult.builtinTrace(); @@ -316,17 +326,6 @@ public Result> evaluateTx(byte[] cbor, Set inputUtx } } - private JulcVm getOrCreateVm() { - if (vm == null) { - synchronized (this) { - if (vm == null) { - vm = JulcVm.create(); - } - } - } - return vm; - } - private com.bloxbean.cardano.julc.vm.JulcVmProvider getProvider() { if (provider == null) { synchronized (this) { diff --git a/julc-cardano-client-lib/src/test/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluatorTest.java b/julc-cardano-client-lib/src/test/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluatorTest.java index 457057b2..6057a9cf 100644 --- a/julc-cardano-client-lib/src/test/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluatorTest.java +++ b/julc-cardano-client-lib/src/test/java/com/bloxbean/cardano/julc/clientlib/eval/JulcTransactionEvaluatorTest.java @@ -16,6 +16,7 @@ import com.bloxbean.cardano.julc.vm.EvalResult; import com.bloxbean.cardano.julc.vm.JulcVm; import com.bloxbean.cardano.julc.vm.PlutusLanguage; +import com.bloxbean.cardano.julc.vm.java.cost.CostModelParser; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -162,6 +163,94 @@ void evaluateTx_alwaysTrue_returnsNonZeroBudget() throws Exception { "Expected non-zero ExUnits"); } + @Test + void evaluateTx_usesSuppliedCostModel_notBuiltInDefault() throws Exception { + // Regression for the provider-instance bug: the cost model was applied to one provider + // instance (getOrCreateVm/JulcVm.create) but evaluation ran on another (getProvider), so the + // chain cost model from ProtocolParams was silently ignored and the VM's built-in default was + // used. We supply two PlutusV3 cost models that differ (one with every cost doubled) via + // ProtocolParams.costModelsRaw and assert the evaluated CPU budget changes. Pre-fix, both runs + // would have produced the same (built-in default) budget and this test would FAIL. + String scriptAddr = buildScriptAddress(alwaysTrueHash); + String txHash = "ab".repeat(32); + + Utxo inputUtxo = Utxo.builder() + .txHash(txHash) + .outputIndex(0) + .address(scriptAddr) + .amount(List.of(Amount.lovelace(BigInteger.valueOf(5_000_000)))) + .build(); + + var redeemer = Redeemer.builder() + .tag(RedeemerTag.Spend) + .index(BigInteger.ZERO) + .data(new BigIntPlutusData(BigInteger.ZERO)) + .exUnits(ExUnits.builder().mem(BigInteger.ZERO).steps(BigInteger.ZERO).build()) + .build(); + + var tx = Transaction.builder() + .body(TransactionBody.builder() + .inputs(List.of(new TransactionInput(txHash, 0))) + .outputs(List.of()) + .fee(BigInteger.valueOf(200_000)) + .build()) + .witnessSet(TransactionWitnessSet.builder() + .redeemers(List.of(redeemer)) + .plutusV3Scripts(List.of(alwaysTrueScript)) + .build()) + .build(); + byte[] cbor = tx.serialize(); + + // A valid PV10 PlutusV3 cost model (297 entries; pvMajor defaults to 10 in the evaluator), + // and a copy with every cost doubled. + long[] baseCosts = CostModelParser.defaultToFlatArray(10); + long[] scaledCosts = new long[baseCosts.length]; + for (int i = 0; i < baseCosts.length; i++) { + scaledCosts[i] = baseCosts[i] * 2; + } + + long baseCpu = evaluateV3SpendCpu(cbor, inputUtxo, baseCosts); + long scaledCpu = evaluateV3SpendCpu(cbor, inputUtxo, scaledCosts); + + assertTrue(baseCpu > 0, "expected a non-zero CPU budget"); + assertNotEquals(baseCpu, scaledCpu, + "supplied cost model must drive the budget; equal values mean the evaluator is " + + "ignoring setCostModelParams and using the built-in default (the bug)"); + assertTrue(scaledCpu > baseCpu, "doubling all costs should increase the CPU budget"); + } + + /** Evaluate the single spend redeemer with a supplied PlutusV3 cost model; return CPU steps. */ + private long evaluateV3SpendCpu(byte[] cbor, Utxo inputUtxo, long[] v3Costs) throws Exception { + UtxoSupplier utxoSupplier = new UtxoSupplier() { + @Override + public List getPage(String address, Integer nrOfItems, Integer page, + com.bloxbean.cardano.client.api.common.OrderEnum order) { + return List.of(); + } + @Override + public Optional getTxOutput(String txHash, int outputIndex) { + return Optional.empty(); + } + }; + ProtocolParamsSupplier protocolParamsSupplier = () -> { + var params = new ProtocolParams(); + params.setMaxTxExMem("14000000"); + params.setMaxTxExSteps("10000000000"); + var raw = new LinkedHashMap>(); + var list = new ArrayList(v3Costs.length); + for (long c : v3Costs) { + list.add(c); + } + raw.put("PlutusV3", list); + params.setCostModelsRaw(raw); + return params; + }; + var evaluator = new JulcTransactionEvaluator(utxoSupplier, protocolParamsSupplier, null); + var result = evaluator.evaluateTx(cbor, Set.of(inputUtxo)); + assertTrue(result.isSuccessful(), "eval should succeed: " + result.getResponse()); + return result.getValue().getFirst().getExUnits().getSteps().longValue(); + } + @Test void evaluateTx_alwaysFalse_returnsError() throws Exception { String scriptAddr = buildScriptAddress(alwaysFalseHash);