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 operation | PV11 lowering |
|---|---|
v.quantityOf(cs, tn), v.getLovelace | lookupCoin |
a + b | unionValue |
a - b | unionValue + scaleValue(-1) (there is no subtract builtin) |
v * factor, -v | scaleValue |
a.containsAtLeast(b) | valueContains |
v.insertCoin(cs, tn, amount), v.withoutLovelace | insertCoin |
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):
| Operation | Portable lowering | Builtin lowering | Improvement |
|---|---|---|---|
quantityOf | 12.7M | 0.99M | 13x |
+ (union) | 125.9M | 4.1M | 31x |
* (scale) | 58.0M | 3.3M | 18x |
containsAtLeast | 110.3M | 1.5M | 75x |
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, andOptions.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 -1This 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
- Protocol Version & Builtins – Which builtins each protocol version provides
- Low-Level Builtins – Manual
Dataaccess and budget comparisons - Measuring Performance – Verify the improvements on your own contracts