Skip to Content
Scalus Club is now open! Join us to get an early access to new features 🎉

Value Operations and CIP-153 Builtins

Protocol version 11 (van Rossem) added native Value builtins to Plutus (CIP-153 ): lookupCoin, unionValue, scaleValue, valueContains, insertCoin, plus the valueData/unValueData conversions. They replace whole SortedMap traversals with single builtin calls costed by the node.

Scalus uses them automatically: when compiling for PV11 (the default target), the compiler rewrites prelude Value operations to these builtins. You keep writing high-level code, and the compiled UPLC gets builtin-level budgets.

// You write this: val paid = output.value.quantityOf(policyId, tokenName) // At PV11 the compiler emits (roughly): // lookupCoin(policyId, tokenName, unValueData(value))

What Gets Rewritten

Value operationPV11 lowering
v.quantityOf(cs, tn), v.getLovelacelookupCoin
a + bunionValue
a - bunionValue + scaleValue(-1) (there is no subtract builtin)
v * factor, -vscaleValue
a.containsAtLeast(b)valueContains
v.insertCoin(cs, tn, amount), v.withoutLovelaceinsertCoin

Not rewritten (they are already cheap or have no builtin counterpart): === (a single equalsData on the underlying Data), flatten, policyIds, tokens, isPositive, hasOnly, lovelaceAmount (deprecated, use getLovelace), and the constructors (Value.zero, Value.lovelace, Value.apply, Value.fromList).

Methods built on the rewritten ones get the builtins too: hasNft is quantityOf(...) === 1, and hasSameTokensAndAtLeastAda uses withoutLovelace and getLovelace.

Measured Improvements

CPU cost of the prelude operation before and after, on a value with 5 policies of 2 tokens each (PV11 mainnet cost model):

OperationPortable loweringBuiltin loweringImprovement
quantityOf12.7M0.99M13x
+ (union)125.9M4.1M31x
* (scale)58.0M3.3M18x
containsAtLeast110.3M1.5M75x

The unValueData conversion at the boundary is a fixed few million CPU, and toData/fromData on Value are identities under the V3 lowering backend, so the rewrite pays for itself from the very first operation: break-even is one call on CPU and zero on memory.

When It Applies

Both conditions must hold:

  • Options.valueBuiltins = true – the default, and
  • Options.targetProtocolVersion >= MajorProtocolVersion.vanRossemPV (PV11) – also the default.

Below PV11 (e.g. Options.plomin) the portable lowering is used and the output is byte-identical to Scalus 1.0.0. To opt out at PV11:

import scalus.compiler.Options given Options = Options(valueBuiltins = false)

Behavior change: canonical form is enforced. The builtins require values in canonical form and fail evaluation otherwise: strictly ascending policy-id and token-name keys (no duplicates), no zero quantities, no empty inner maps, keys of at most 32 bytes, and quantities within ±(2^127 - 1). unionValue and scaleValue additionally fail on 128-bit overflow, and valueContains fails when either side holds a negative amount.

Values the ledger puts in your ScriptContext are always canonical. The values that gain validation are the user-controlled ones you decode from datums and redeemers – with the portable lowering, a malformed map was silently traversed; with the builtins, the transaction fails. If a contract must accept non-canonical values, compile it with valueBuiltins = false.

Choosing the Right Check

“The output pays at least X” – use containsAtLeast. It is one valueContains call instead of a walk over every policy and token:

require(output.value.containsAtLeast(datum.escrowed + fee), "underpaid")

“Exactly this one token was minted/burned” – use hasOnly. It is not builtin-lowered, but it checks the policy’s token map with a single equalsData and is portable to every protocol version. It only constrains the given policy, so other scripts can mint in the same transaction:

// from the PriceBet oracle example require(tx.mint.hasOnly(policyId, config.beaconName, 1), "must mint exactly one beacon") // burning: amount -1

This is roughly 35% cheaper in fee than the equivalent tx.mint.tokens(policyId) === SortedMap.singleton(beaconName, BigInt(1)).

“How much ADA is in this output” – use getLovelace. It is one lookupCoin call. The deprecated lovelaceAmount walks the Data map to the first entry instead: about 30 more machine steps per call, and on a value without lovelace it returns another token’s quantity. Replacing 7 lovelaceAmount calls in a port of the Midgard DA bond pool validator cut the script from 2485 to 2431 bytes and memory by 6–10% per transaction:

val outLovelace = out.value.getLovelace require(outLovelace - poolIn.value.getLovelace >= minTopUp, "top-up is below the minimum")

Reading the amount is fine. Comparing only the ADA of a script UTxO lets native tokens slip out; pin the token set too, with hasSameTokensAndAtLeastAda or withoutLovelace.

Adjusting a value – insertCoin(cs, tn, amount) replaces the quantity (it does not add); amount 0 deletes the coin and drops the policy entry when it becomes empty, keeping the value canonical. withoutLovelace is defined as insertCoin(adaPolicyId, adaTokenName, 0).

withoutLovelace changed semantics with this work: it used to drop the whole entry under the ADA policy id. For ledger-shaped values the result is identical; a non-ADA token stored under the empty policy id (which the ledger never produces) now survives.

See Also

Last updated on