Datum Shape Validation
Scalus decodes on-chain data lazily: fromData[T] compiles to nothing, and fields are read from
the underlying Data only when your code touches them. There is no constructor-tag or
field-count check at the validator boundary. This page explains why that is the default, where
the residual risk actually lives, and what to do about it.
Lazy by Default, on Purpose
Toolchains differ here, though less than you might expect. Aiken’s expect decodes a typed
datum strictly at the Data boundary (tag, exact field count, deep). Plinth’s default derived
decoder checks the constructor index on every type, including single-constructor ones, but
accepts extra trailing fields; its asData mode drops the index check on the grounds that a
type with one constructor has nothing to discriminate. Plutarch’s pmatch on a single-variant
data type skips the check too, and offers PTryFrom as the opt-in strict decoder. Pebble goes
further and warns you that testing the constructor of a one-constructor struct is redundant.
Scalus is in that second group: like plu-ts, Plutarch and Plinth’s asData, it trusts the datum
and projects fields lazily.
Strict-by-default sounds safer, but it adds a catalogued vulnerability: arbitrary-datum bricking. If a validator rejects any output whose datum does not decode to the expected type, then an attacker (or a buggy wallet) that sends a UTxO to the script address with a wrong-shaped datum creates an output that can never be spent, even though the validator would never have looked at that datum’s content. Lazy projection is immune: a datum the validator never reads can never fail to decode.
The trade is two different exposures:
- Strict boundary decoding buys shape guarantees at the cost of robustness against arbitrary-datum bricking.
- Lazy projection keeps that robustness; the residual risk is shape smuggling into your own protocol state (below).
The Constructor Tag
For a single-constructor type (a plain case class), reading a field compiles to
sndPair(unConstrData d) plus list drops. The tag is never compared, so any Constr(k, ...) is
accepted:
case class State(owner: ByteString, counter: BigInt)
// all three return 42
d.to[State].counter // d = Constr(0, [B #deadbeef, I 42])
d.to[State].counter // d = Constr(1, [B #deadbeef, I 42])
d.to[State].counter // d = Constr(99, [B #deadbeef, I 42])match does not check it either: a single-constructor match lowers to the same projection. The
one guard that remains is unConstrData itself, so a Data.I, Data.B, Data.List or
Data.Map still fails the script.
Sum types are different. An enum or sealed hierarchy dispatches on the tag, and at PV11
(the default target) an out-of-range tag fails the script. That holds even for a one-case enum.
Pre-PV11 targets (Options.plomin) treat the last constructor as an unconditioned else, which
matches Aiken’s behaviour for an exhaustive when.
Re-encoding uses the declared tag
The unpacked form of a product is a plain List[Data], which carries no tag. When such a value is
turned back into Data, Scalus emits the tag declared by the type, not the one that arrived.
This happens when the value crosses a non-inline function boundary:
@Compile
object Helpers:
def reencode(s: State): Data = s.toData // the parameter takes the unpacked form
compile { (d: Data) => Helpers.reencode(d.to[State]) }
// d = Constr(1, [B #deadbeef, I 42]) -> Constr(0, [B #deadbeef, I 42])Inside a single expression the value keeps its packed form and the tag survives:
compile { (d: Data) =>
val s = d.to[State]
s.toData // Constr(1, ...) stays Constr(1, ...)
}Well-formed values are never affected. A value whose tag already matches its type round-trips unchanged, including a variant of a sealed hierarchy that legitimately carries a non-zero tag. Extra trailing fields also survive; only the tag is rewritten.
This is the mechanism behind the inlineOrFail[T] === x warning in
Equality vs Field Reads: the decoded value is held as a field list, and
the rewrap before the comparison stamps the declared tag on it. hasInlineDatum avoids the round
trip entirely by comparing the original Data.
Two rules this implies
First: never cast to a concrete variant of a sealed hierarchy. Match on the parent instead.
enum Order derives FromData, ToData:
case Buy(amount: BigInt) // tag 0
case Sell(amount: BigInt) // tag 1
// WRONG: a Sell's bytes are read as a Buy, and re-encoding emits a canonical Buy
val buy = redeemer.to[Order.Buy]
// RIGHT: the parent match checks the tag
redeemer.to[Order] match
case Order.Buy(amount) => ...
case Order.Sell(amount) => ...Second: compare datums in the form you received them. Comparing two re-encoded values can report
equal for byte-different datums, so for a continuing output use out.hasInlineDatum(expected),
which compares the original Data byte for byte.
Checking the tag yourself
When a single-constructor datum comes from an untrusted source and its tag matters to you, guard it explicitly:
require(datum.toConstr.fst === BigInt(0), "unexpected constructor tag")At PV11 that costs roughly 165 lovelace on top of a plain field read, at current mainnet prices.
Why “Huge Datum” Attacks Are Weaker Than They Look
- All Plutus
Dataaccessors are constant-cost; datum size is irrelevant to a validator that does not traverse it.equalsDatais charged on the smaller operand, so a giant datum cannot even make a comparison expensive. Only explicit iteration (andserialiseData) scale with attacker-controlled size. - Inline datums are self-limiting: min-ada charges roughly 4.4 ADA per KiB, so a 16 KB inline datum permanently sinks about 70 ADA.
- The dangerous variant is the datum hash: it costs about 1 ADA regardless of preimage size, and an output carrying 32 random bytes as its datum hash is unspendable forever (no preimage can ever be supplied). Never accept datum-hash outputs as protocol state.
Where the Real Risk Is
Shape validation cannot detect a well-formed fake datum; authenticating the UTxO (state-thread or one-shot NFT) is what protects you there. The actual exposure is the continuing output’s datum when a validator checks it field by field: an attacker appends extra trailing fields, they become protocol state, every later spend that traverses the datum pays more, and the state UTxO can eventually exceed the execution budget. Byte-different datums that decode to the same typed value also silently fork anything keyed on datum bytes or hashes.
Whole-datum equality already closes this hole: comparing the output’s datum as Data is a
byte-exact equalsData, and it is cheap.
// Field-wise check: extra trailing fields are smuggled through
val newDatum = contractOutput.datum.inlineOrFail[VestingDatum]("not inline")
require(newDatum.owner === datum.owner && newDatum.deadline === datum.deadline)
// Whole-datum check: byte-exact, no smuggling possible
require(contractOutput.hasInlineDatum(expectedDatum), "unexpected continuing datum")Equality vs Field Reads
There are two ways to look at a continuing output’s inline datum, and they are not interchangeable:
out.hasInlineDatum(x)when the datum must equal a known value. It wrapsxas anOutputDatumand compares the wholeDatawith oneequalsData. Measured at 286 lovelace per comparison.out.datum.inlineOrFail[T](msg)when the datum’s fields are needed. It unwraps theOutputDatum, fails if the datum is a hash or absent, and hands back a lazily decodedT. Decoding and then comparing with=== xcosts 461 lovelace for the same check, because the decoded value is held as a field list and rewrapped before the comparison.
// Equality: compare the Data once
require(contractOutput.hasInlineDatum(vestingDatum), InvalidDatum)
// Field reads: decode, then use the fields
val next = contractOutput.datum.inlineOrFail[Config](NotInlineDatum)
require(next.beneficiary === config.beneficiary, BeneficiaryChanged)
require(next.startTimestamp === config.startTimestamp, StartChanged)The two forms also differ on a malformed datum. inlineOrFail[T] does not validate the
shape: a datum with the wrong constructor tag decodes lazily and no error is raised until a field
is read, if ever. When such a value is compared with === x, the rewrap hard-codes the
constructor tag as 0, so a datum with a wrong tag and matching fields compares equal.
hasInlineDatum compares the original Data, tag included, so a wrong tag simply fails the
equality. For a continuing-output check that is the behaviour you want: the attacker’s datum is
rejected without the validator having to know what was wrong with it.
Never write out.datum.inlineOrFail[T](msg) === x for a pure equality check. It is the dearer
form and the weaker one. The migrated examples use hasInlineDatum for every continuing datum
comparison (VestingValidator, EscrowValidator, AmmValidator, Auction); see
Equality and Lookups
for the measured table.
Guidance
In priority order:
- Authenticate protocol UTxOs with a state-thread or one-shot NFT. Shape validation cannot detect a well-formed fake; this is the defence that matters most.
- Check continuing-output datums with
out.hasInlineDatum(expected), not field by field. UseinlineOrFail[T]only when the fields are needed. - Never cast to a concrete variant of a sealed hierarchy; match on the parent type, which checks the constructor tag. See The Constructor Tag.
- Require inline datums on protocol outputs. Never accept a datum-hash output as protocol state.
- Handle the PlutusV3 no-datum branch explicitly (on V3 the script runs with
None; on V1/V2 a missing datum is a phase-1 rejection). - Bound anything you iterate: datum-carried lists and token counts in a value. Real UTxOs have been bricked by exceeding the memory budget with 150+ assets, well under the size limit.
- Validate
ByteStringlengths used as credentials. Nothing forces a key hash to be 28 bytes; a zero-length or over-long value can make a required output impossible to build.
Opt-In Validation
When your logic depends on a decoded structure being well-formed, validate it explicitly at the point of use:
SortedMapdecoded from a datum is not checked for key order by the defaultFromDatainstance; a deliberately mis-ordered map can make lookups miss entries. Decode withsortedMapFromDataWithValidationwhen the order matters (see Collections).- At PV11,
Valueoperations lowered to CIP-153 builtins reject non-canonical values for free (see Value Builtins).
A general opt-in deep validator with Aiken-equivalent semantics (an expect-style combinator
that checks constructor tags, exact field counts and nested shapes) is planned. Until then,
hasInlineDatum plus the guidance above covers the known attack surface.
See Also
- Common Vulnerabilities – Known vulnerability patterns and mitigations
- Safe API Cheatsheet – The fail-fast form of every lookup, one line per operation
- Equality and Lookups – Measured costs behind the rules on this page
- Plutus Data – How
toData/fromDatawork - Design Patterns – State-thread NFTs and other structural defences