Common Plutus Vulnerabilities
This guide lists the known vulnerability classes of Cardano validators, grouped by family and
ordered by how often they appear in real audits. Every entry ends with the Scalus operation or
idiom that closes it, with a snippet from one of the example validators in scalus-examples.
The short labels (DS-1, VP-2, …) are the vocabulary the /security-review skill uses, so a
finding in a review report maps to a section here.
A Cardano validator only validates; it never acts. Every script input in a transaction runs
its own validator, and every run sees the same TxInfo. Most classes below are one mistake in
different clothes: a check phrased as “some output satisfies X” instead of “this exact output
satisfies exactly X”.
Double Satisfaction (DS)
DS-1: One output satisfies two script inputs
Problem: Two UTxOs locked by the same script are spent in one transaction. Each validator run looks for “an output paying the beneficiary”, and both find the same output.
Mitigation: Allow only one own-script input per transaction, or tag every payout with the
spent input’s TxOutRef, or fold over all own inputs and outputs and compare the sums.
Scalus: the single-input guard is findUniqueOrFail on the inputs. It fails unless exactly one
input sits at the script’s payment credential. .filter(...).head is the anti-idiom.
// scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingValidator.scala
txInfo.inputs.findUniqueOrFail(
_.resolved.address.credential === ownCredential,
MultipleVestingInputs
)The tagged-output defence is hasPaidTagged. All three comparisons are exact: whole address,
whole value with === (never >=), and the datum tag. A TxOutRef-derived tag is unique across
every script and every instance:
val tag = OutputDatum.OutputDatum(ownRef.deriveTokenName.toData)
require(tx.hasPaidTagged(beneficiary, Value.lovelace(price), tag), "must pay the beneficiary")DS-2: Cross-instance double satisfaction
Problem: The single-input guard is scoped to one script hash. Two parameterized instances of the same validator have different hashes, so each sees one own input, and both share one payout.
Mitigation: Tag the payout with something unique to this instance: the input’s TxOutRef
(hasPaidTagged above) or the instance’s own script hash.
// scalus-examples/jvm/src/main/scala/scalus/examples/auction/Auction.scala
require(sellerOutput.hasInlineDatum(scriptHash), "Seller output must be tagged with this auction's id")Value Preservation (VP)
VP-1: Value not preserved on the continuing output
Problem: The validator checks the datum transition but never the value of the continuing
output, or the datum records a balance never tied to the actual Value. The attacker keeps the
difference. This is the most frequent bug in reviewed validators.
Mitigation: Compare the whole continuing value with === against a value computed from the
input. If the datum carries a balance, tie it to the real token quantity with quantityOf (as
AmmValidator does for its reserves). When the output may gain ADA but must keep every token, use
hasSameTokensAndAtLeastAda(expected).
// scalus-examples/jvm/src/main/scala/scalus/examples/simpletransfer/SimpleTransferValidator.scala
require(contractOutput.value === balance + amount, "Contract has received incorrect amount")VP-2: Lovelace-only comparison
Problem: value.getLovelace is the easy accessor, so the check is written in lovelace. It
passes while every native token in the UTxO goes to the attacker.
Mitigation: Compare the whole Value. If the protocol is ADA-only by design, prove it once at
the boundary with withoutLovelace.isZero instead of assuming it in every comparison.
// scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingValidator.scala
require(
contractOutput.value === ownInput.value - Value.lovelace(requestedAmount),
ContinuingValueMismatch
)
// scalus-examples/jvm/src/main/scala/scalus/examples/paymentsplitter/PaymentSplitterValidator.scala
require(input.resolved.value.withoutLovelace.isZero, "Contract input must contain only ADA")VP-3: Continuing output redirected
Problem: The validator reads “the output at index i” from the redeemer and checks its value
and datum but never its address, so the attacker points i at an output they control.
Mitigation: Locate the continuing output by the whole input address with
findContinuingOutputOrFail(ownInput, msg), which fails unless exactly one output matches (see
AU-4 for the staking part).
// scalus-examples/jvm/src/main/scala/scalus/examples/escrow/EscrowValidator.scala
val contractOutput =
txInfo.findContinuingOutputOrFail(ownInput, "Expected exactly one contract output")VP-4: >= where an exact payout was meant
Problem: “At least the required amount” looks conservative. It lets one over-sized output satisfy two obligations at once, which is what enables DS-1.
Mitigation: Payouts and refunds compare with ===. Reserve >= for cases where an excess is
genuinely allowed, and say so in the message.
// scalus-examples/jvm/src/main/scala/scalus/examples/auction/Auction.scala
require(refundOutput.value.getLovelace === currentHighestBid, "Previous bidder must receive exactly their bid amount")Authentication (AU)
AU-1: Unauthenticated UTxO or reference input
Problem: Anyone can create a UTxO at any address with any datum. A validator that locates protocol state by address alone (“the input from the oracle script”) reads attacker-supplied data. Reference inputs feel read-only and safe; they are not.
Mitigation: Authenticate protocol UTxOs by a beacon NFT whose minting policy guarantees
uniqueness (MI-2). Check the beacon with hasNft, not the address.
// scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/PricebetValidator.scala
require(
oracleInput.resolved.value.hasNft(config.oracleScriptHash, OracleBeaconName),
OracleInputMustHaveBeacon
)AU-2: Missing signature on a state transition
Problem: One branch of the redeemer enum forgets its authorization check. Nothing is structurally wrong; a required predicate is absent, and anyone can submit that transition.
Mitigation: Every branch that is not deliberately permissionless checks tx.isSignedBy(key);
a set of allowed signers uses tx.isSignedByAny(keys). Keep a test per branch that submits it
from an unrelated key and expects a failure.
// scalus-examples/jvm/src/main/scala/scalus/examples/htlc/HtlcValidator.scala
require(tx.isSignedBy(config.committer), UnsignedCommitterTransaction)AU-4: Staking credential swapped on the continuing output
Problem: An address is a pair: a payment credential and an optional staking credential. A
check on the payment credential alone accepts an output at (scriptHash, attackerStakeKey). The
attacker cannot spend the funds, but collects every staking reward on the protocol’s TVL, and the
DS-1 single-input guard breaks because the funds now sit at many distinct addresses.
Mitigation: Compare the whole Address. findContinuingOutputOrFail matches the full input
address; the credential-only finders (findOutputsByCredential, findOutputsByScriptHash) are
wrong for the continuing output. Never rebuild the expected address with Address.fromScriptHash,
which has no staking part.
// scalus-examples/jvm/src/main/scala/scalus/examples/vault/VaultValidator.scala
// The unique continuing output to the vault's WHOLE address (staking part included), so a
// spender cannot redirect the vault's delegation rewards by changing the staking credential.
private def getVaultOutput(tx: TxInfo, ownInput: TxInInfo): TxOut =
tx.findContinuingOutputOrFail(ownInput, NotExactlyOneVaultOutput)AU-6: Parameterized validator substitution
Problem: A script cannot verify on-chain that a counterpart script is the correct
instantiation of a known template, so a user may interact with an attacker’s identically-shaped
instance whose owner parameter is the attacker.
Mitigation: Store parameters in a beacon-authenticated UTxO instead of baking them into the
script. Where a dependent script must know an instance, compute the expected hash off-chain and
bake it in (the ParameterValidation pattern in scalus-design-patterns). Publish the instance
hash in the blueprint so users can verify it.
Minting (MI)
MI-1: Other token name
Problem: A policy asserts “one unit of my expected token is minted” and says nothing about
other token names under the same policy, so the attacker mints a counterfeit alongside it. This is
the Minswap incident class: summing across token names conflates mints and burns, so “total under
my policy is 1” also passes for +2 of A and -1 of B.
Mitigation: Compare the whole sub-map under the policy. tx.mint.hasOnly(policy, name, qty)
is the mint idiom; qty is signed, so the same call pins a burn. When the name is not known in
advance, tx.mint.tokens(policy).singleOrFail(msg) yields the only entry and fails on zero or
several.
// scalus-examples/jvm/src/main/scala/scalus/examples/amm/AmmValidator.scala
require(tx.mint.hasOnly(policyId, poolNftName, 1), "Init: must mint exactly one pool NFT")
// ...and on Close:
require(tx.mint.hasOnly(policyId, poolNftName, -1), "Close: must burn exactly the pool NFT")MI-2: One-shot seed not bound
Problem: A one-shot policy is parameterized by a seed TxOutRef and must require that exact
ref to be spent. A check weakened to “some input exists at index i” never compares against the
seed, so the “unique” NFT is mintable forever, and every beacon built on it (AU-1) is forgeable.
Mitigation: Bind the seed explicitly, and derive the token name from it with deriveTokenName
so the on-chain and off-chain sides cannot disagree.
// scalus-examples/jvm/src/main/scala/scalus/examples/editablenft/EditableNftValidator.scala
require(tx.inputs.at(seedIndex).outRef === seed, MustSpendSeed)
// scalus-examples/jvm/src/main/scala/scalus/examples/crowdfunding/Crowdfunding.scala
val campaignId = consumedUtxo.deriveTokenName
require(txInfo.mint.hasOnly(policyId, campaignId, 1), "Exactly one campaign NFT must be minted")MI-3: Burn checks that pass without a burn
Problem: tokens(policy).forall(_._2 < 0) is vacuously true on an empty map, so a “burn only”
branch passes a transaction that burns nothing. A <= on the quantity accepts a partial burn; a
sign error lets a mint pass a burn check.
Mitigation: onlyBurnsUnder(policy) requires at least one entry under the policy and every
quantity negative. When the name is known, mint.hasOnly(policy, name, -qty) is stronger.
// scalus-examples/jvm/src/main/scala/scalus/examples/auction/Auction.scala
require(txInfo.onlyBurnsUnder(policyId), "Only burning is allowed (all amounts must be negative)")Time (TI)
TI-1: Unbounded validity range used as “now”
Problem: A validity bound may be infinite, and any helper that turns it into a number must
invent a value for that case. The deprecated getValidityStartTime returned 0, so a transaction
with no lower bound was treated as happening at the Unix epoch and every deadline comparison
flipped. The transaction author picks the validity range, so a missing bound is an attacker
capability, not an accident.
Mitigation: Never project the interval to a scalar without proving the bound is finite.
validFromOrFail(msg) and validToOrFail(msg) fail closed on an unbounded range; the interval
predicates validRange.isEntirelyAfter(t) and isEntirelyBefore(t) return false on one. Pick
the bound the attacker cannot move in their favour: a lower bound can be backdated freely, while
the upper bound cannot be earlier than the real posting time (VaultValidator derives its
finalization deadline from validToOrFail for this reason).
// scalus-examples/jvm/src/main/scala/scalus/examples/htlc/HtlcValidator.scala
case Action.Timeout =>
val validFrom = tx.validFromOrFail(ValidRangeMustBeBound)
// validFrom is inclusive, hence 10 <= 10 is correct
require(config.timeout <= validFrom, InvalidCommitterTimePoint)TI-2: Bound inclusivity
Problem: An off-by-one at a deadline gives both the claimant and the refunder a valid path in the same slot.
Mitigation: The ledger builds the lower bound closed and the upper bound open. validFromOrFail
is inclusive; validToOrFail is exclusive, so a timestamp written from it can be late but never
early. State which one you use in the message, as the HTLC snippet above does.
Datum (DT)
DT-1: Datum hijacking
Problem: A transition validates the field that changed and forgets the rest. The attacker
rewrites owner while keeping amount, and the field-wise check passes. Extra trailing fields
are smuggled through the same way.
Mitigation: Build the complete expected datum and compare the whole thing; copy on the old
datum pins every field you do not name. hasInlineDatum(expected) is one data equality with no
decoding, measured at 286 lovelace against 461 for decode-then-compare. Use inlineOrFail[T]
when you need the fields, not equality.
// scalus-examples/jvm/src/main/scala/scalus/examples/amm/AmmValidator.scala
val expectedDatum = AmmMath.depositDatum(datum, x0, x1)
require(poolOutput.hasInlineDatum(expectedDatum), "Deposit: output datum mismatch")DT-3: Datum-hash bricking and missing datums
Problem: An output at a script address may carry only a datum hash. If the preimage is never
published, the UTxO is unspendable forever, and it costs the attacker one min-ADA to plant. On
Plutus V3 the spending datum is Option[Data] and may be absent entirely.
Mitigation: Require an inline datum on every output the protocol creates and never accept
a datum-hash output as protocol state. Handle the no-datum branch with datum.getOrFail(msg).
Locate protocol UTxOs by beacon (AU-1), so planted junk is invisible. See
Datum Validation for the lazy-decoding model.
Indices and Ordering (IX)
IX-1 and IX-2: Index lists and missed inputs
Problem: Batch validators take index lists in the redeemer. Three bugs live there: the same
index twice (one payout counted for two obligations), a list shorter than the item list with zip
silently truncating, and an extra script input that no index names and that is therefore spent
with no validation at all.
Mitigation: Check the index list length against the item list and reject duplicates. Verify
coverage in both directions: every named index is an own input, and every own input is named.
List.at fails on an out-of-range index. Inputs are sorted by the ledger; outputs keep the
author’s order. UtxoIndexer.multiOneToOneNoRedeemer in scalus-design-patterns walks every
input at the script credential and fails when one has no index pair; the singular indexer
patterns solve missed inputs, not double satisfaction.
// scalus-examples/jvm/src/main/scala/scalus/examples/crowdfunding/Crowdfunding.scala
val isInDonationList = donationInputIndices.exists { idx =>
txInfo.inputs.at(idx).outRef === txOutRef
}
require(isInDonationList, "Donation UTxO must be in donationInputIndices")Script Purposes (PU)
PU-1: Other redeemer and unimplemented purposes
Problem: A script that runs under several purposes or redeemer branches is only as strong as its weakest branch. A second script that relies on “the global validator ran” is bypassed when the global validator ran under a different, weaker redeemer.
Mitigation: The Scalus compiler plugin generates a fail for every purpose a validator does
not implement, so a single-purpose validator rejects minting, rewarding and certifying by default.
When a counterpart script must run, assert which redeemer it ran with:
tx.redeemers.getOrFail(purpose, msg) and compare the decoded value.
// scalus-examples/jvm/src/main/scala/scalus/examples/htlc/HtlcValidator.scala
ctx.scriptInfo match
case ScriptInfo.SpendingScript(txOutRef, datum) =>
spend(datum, ctx.redeemer, ctx.txInfo, txOutRef)
case _ => fail(MustBeSpending)PU-2: Withdraw-zero forwarding proves it ran, not how
Problem: The withdraw-zero trick runs a stake validator once per transaction instead of once per input. A spending validator that only checks “a withdrawal for my stake script exists” does not know which branch of the stake script accepted it.
Mitigation: StakeValidator.spendMinimal in scalus-design-patterns proves the withdrawal
script ran and nothing more. Use StakeValidator.spend with a redeemer validator to pin the
endpoint when the stake script has more than one branch.
Evaluation Order (EV)
EV-1: A required check short-circuited away
Problem: UPLC control flow is lazy. && and || short-circuit, and only the taken branch of
an if runs. With isEmergency || (signed && valuePreserved), nothing else is checked when
isEmergency is true.
Mitigation: Every obligation is its own require with its own message. Validation callbacks
return Unit and fail inside; a Boolean callback throws the message away.
require(isEmergency || tx.isSignedBy(owner), "authorized")
require(out.value === expected, "value preserved") // unconditionalThe validator callbacks of UtxoIndexer, StakeValidator and TransactionLevelMinterValidator
in scalus-design-patterns take => Unit. An older Boolean lambda still compiles against a
Unit parameter (value discard) and then checks nothing. After upgrading, grep every callback
passed to these patterns and wrap its condition in require(..., message).
Arithmetic (AR)
AR-1: Rounding direction
Problem: On-chain integers do not overflow, but they round. In any amount * rate / denominator, the rounding direction decides who absorbs the remainder, and an attacker who splits
one large action into many small ones harvests one rounding unit each time. Plain / on BigInt
compiles to divideInteger, which floors; Scala’s / truncates toward zero, so the two disagree
on negative operands.
Mitigation: Name the direction: a divFloor b or a divCeil b. Round the protocol’s share
down and the user’s obligation up. An alphanumeric infix operator has the lowest precedence, so
parenthesize operand expressions.
// scalus-examples/jvm/src/main/scala/scalus/examples/cape/linearvesting/LinearVestingValidator.scala
// The remaining locked quantity rounds UP at every step: nothing vests early.
val expectedRemaining = (futureInstallments * d.totalVestingQty) divCeil d.totalInstallments
// scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingValidator.scala
// The released amount rounds DOWN: the beneficiary never gets a fraction early.
vestingDatum.initialAmount * elapsed divFloor vestingDatum.durationAR-2: Negative quantities
Problem: Datum and redeemer fields are attacker-supplied. A negative “withdrawal” is a deposit the validator did not intend to allow, and a negative fee is a payout.
Mitigation: Check the sign of every quantity that enters from the redeemer or datum before
using it in arithmetic: require(requestedAmount > 0, NonPositiveAmount) in VestingValidator.
Resources and Denial of Service (RS)
RS-1: Token dust on a protocol UTxO
Problem: Anyone can add native tokens to any output they create. A protocol UTxO that accumulates hundreds of asset classes approaches the maximum value size, blows the execution budget of any validator that iterates its value, and pushes the min-ADA above what the UTxO holds.
Mitigation: Constrain the token set on every output the protocol creates. hasOnly pins a
single-asset value; withoutLovelace.isZero pins an ADA-only one.
// scalus-examples/jvm/src/main/scala/scalus/examples/vault/VaultValidator.scala
require(value.withoutLovelace.isZero, CannotAddTokens)RS-2: Large datum
Problem: Oversized datums on UTxOs that must be consumed for critical operations exhaust the budget of every later spend and can exceed the transaction size limit.
Mitigation: Bound every collection a validator iterates, and keep large data off-chain behind
a hash. Compare continuing datums with whole-datum hasInlineDatum, which is constant-cost.
RS-4: Quadratic scans
Problem: Every spent script input runs the validator, and each run that iterates all inputs is linear, so the transaction is quadratic. A protocol that works with three inputs fails at fifteen.
Mitigation: Move batch validation to a stake validator that runs once per transaction
(StakeValidator in scalus-design-patterns, as in OptimizedPaymentSplitterValidator), and
pin the budget at several input counts in tests.
RS-5: UTxO contention
Problem: A single global-state UTxO must be consumed by every operation, so concurrent users race and all but one fail. An attacker who spends and recreates that UTxO on every block halts the protocol.
Mitigation: Design for per-user UTxOs, sharded state, or batching through a stake validator. Add cancellation fees, freezing periods, or minimum time locks as economic disincentives. Submit competing transactions in an Emulator test so contention shows up before deployment.
Design-Level (DE)
DE-1: Locked value
Problem: A state with no outgoing transition, or a branch whose precondition can never hold, locks the UTxO forever. The classic case is a bet with no timeout path when the oracle goes silent.
Mitigation: Every state has an exit. Give time-gated flows a reclaim branch, gated by
validRange.isEntirelyAfter(expiration) and a participant’s signature (the Timeout action in
BettingValidator), and test that it is reachable.
DE-2: Oracle attacks
Authenticity. An oracle datum located by address alone is AU-1. Authenticate the oracle UTxO
by its beacon NFT (hasNft) before reading its datum.
Freshness. Stale data leads to wrong decisions. Compare the oracle timestamp against the transaction’s validity range and reject data older than the protocol’s threshold.
// scalus-examples/jvm/src/main/scala/scalus/examples/pricebet/PricebetValidator.scala
require(
validRange.isEntirelyAfter(oracleState.timestamp),
"Oracle timestamp must be within transaction validity range"
)Manipulation. Spot prices can be moved within one block. Prefer time-weighted averages, cap the reportable change per update, and aggregate several sources.
Key compromise. Oracle keys are high-value targets. Support on-chain key rotation and expiry, require multi-signature oracles, and verify oracle signatures on-chain with a domain-separated payload so a signature cannot be replayed across instances.