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, and the constructors (Value.zero, Value.lovelace, Value.apply, Value.fromList).

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)).

Adjusting a valueinsertCoin(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