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

Safe API Cheatsheet

The safe API is a small set of on-chain operations whose semantics close a vulnerability class: the obvious hand-written expansion is wrong in a way real contracts have paid for, and the named operation removes the mistake. Three conventions hold throughout:

  • Fail-fast is primary. A lookup that must succeed is xOrFail(message); the Option form exists only where the caller branches.
  • Predicates plus require. Checks return Boolean and you write require(check, message). There are no requireX wrappers.
  • Safety in semantics, not naming. findContinuingOutputOrFail compares the whole address; onlyBurnsUnder refuses an empty mint. The name says what is checked, and the body checks it.

Most of what a validator needs was already in the library. The first table lists those idioms; the nineteen operations that follow are the additions. Do not rewrite an idiom into a helper of your own.

Already in the library

CheckIdiomAnti-idiom it replaces
Exact mint or burn, nothing else under the policytx.mint.hasOnly(policy, name, qty) (qty is signed)quantityOf(policy, name) === qty alone (MI-1: other names slip through)
Multi-asset exact minttx.mint.tokens(policy) === expectedper-name quantityOf checks
Nothing / anything minted under a policytx.mint.tokens(policy).isEmpty / .nonEmptysize == 0; hand-written mintsNothing helpers
ADA onlyvalue.withoutLovelace.isZerovalue.toSortedMap.size === BigInt(1)
Deadline passed / not yettx.validRange.isEntirelyAfter(t) / isEntirelyBefore(t)raw bound reads with a default of 0
Signed by one; by all; how manytx.isSignedBy(pkh); keys.forall(tx.isSignedBy); keys.count(tx.isSignedBy)tx.signatories.filter(...).length
Inline datum decode (fields needed)out.datum.inlineOrFail[T](msg)datum match { case OutputDatum(d) => d.to[T] } with @unchecked
Map lookup that must succeedmap.getOrFail(key, msg)map.get(key).get
Finite interval boundbound.finiteOrFail(msg)bound.finite(0) (invents a time)
Enterprise address for a scriptAddress.fromScriptHash(h)building Address(cred, None) and comparing it to a real output
Credential projection, branchingcred.scriptOption / cred.pubKeyOptiondestructuring with : @unchecked
Any / every element; count; indexlist.contains(x), forall, count(p), at(i)exists (allocates an Option), filter(p).length
Structural equalitya === ba.toData == b.toData (identical UPLC, so never write it)

The nineteen operations

Grouped by receiver. The last column is the hand-written line the operation replaces.

List, SortedMap, AssocMap

SignaturePreventsReplaces
List.findUniqueOrFail(predicate: A => Boolean, inline message: String): ADS-1 double satisfaction: find stops at the first match and accepts a secondfilter(p).length === BigInt(1) then .head; two traversals
List.singleOrFail(inline message: String): AIX-1 “first of many”: head accepts the first of fivelist.head after an unchecked size assumption
SortedMap.singleOrFail(inline message: String): (A, B) (and AssocMap.singleOrFail, same body)MI-1 / MI-3 partial mint or burn: extracts the one entry and fails on two or morematch on tokens(p).toList with a PairCons(kv, PairNil) arm

Credential

SignaturePreventsReplaces
Credential.scriptHashOrFail(inline message: String): ValidatorHashAU-3 own-hash confusion; no Option allocationcred.scriptOption.getOrFail(msg); case ScriptCredential(h) => h with @unchecked
Credential.pubKeyHashOrFail(inline message: String): PubKeyHashsame, for keyscred.pubKeyOption.getOrFail(msg)

Value

SignaturePreventsReplaces
Value.hasNft(cs: PolicyId, tn: TokenName): BooleanAU-1 planted UTxO: “exactly one” is the NFT invariant; > 0 is a different predicatequantityOf(p, n) === BigInt(1) (and the drift to > 0)
Value.hasSameTokensAndAtLeastAda(expected: Value): BooleanVP-1 >= on the whole value; VP-6 min-ADA griefing with ===out.value === expected or out.value.containsAtLeast(expected)

TxOut

SignaturePreventsReplaces
TxOut.hasInlineDatum[A: ToData](a: A): BooleanDT-1 datum continuity, field-wise checks that let trailing fields through; measured 286 lovelace against 461 for the decode formout.datum.inlineOrFail[T](msg) === expected; out.datum === OutputDatum(x.toData)

TxInfo

SignaturePreventsReplaces
TxInfo.findContinuingOutputOrFail(ownInput: TxInInfo, inline message: String): TxOutAU-4 staking swap: compares the whole address, the credential-only finders do not; DS-1 via uniquenessfindOutputsByCredential(cred) then .length === BigInt(1) then .head
TxInfo.valuePaidTo(addr: Address): ValueVP-2 lovelace-only sums that let native tokens be strippedUtils.getAdaFromOutputs(outs); outputs.filter(_.address === a).map(_.value.getLovelace).sum
TxInfo.valueSpentFrom(addr: Address): Valuesame, over inputsUtils.getAdaFromInputs(ins)
TxInfo.isSignedByAny(keys: List[PubKeyHash]): BooleanAU-2 multi-owner authorization without the Option tax of existskeys.exists(tx.isSignedBy)
TxInfo.validFromOrFail(inline message: String): PosixTimeTI-1 unbounded range used as “now”: fails instead of returning 0; the bound is inclusivetx.getValidityStartTime; validRange.from.finite(0)
TxInfo.validToOrFail(inline message: String): PosixTimeTI-2 inclusivity: the bound is exclusive, a datum timestamp taken from it can be late, never earlyvalidRange.to.boundType match { case Finite(t) => t }
TxInfo.onlyBurnsUnder(policy: PolicyId): BooleanMI-3 vacuous truth: forall on an empty mint map is true, so a Close that mints nothing passestx.mint.tokens(policy).forall(_._2 < 0)
TxInfo.hasPaidTagged(addr: Address, value: Value, tag: OutputDatum): BooleanDS-1 / DS-2 across instances: exact address, exact value, unique taga fold over outputs with >= on the value

TxOutRef

SignaturePreventsReplaces
TxOutRef.deriveTokenName: TokenNameMI-2 one-shot seed: one definition the off-chain side can mirror byte for byteblake2b_256(serialiseData(ref.toData)) copied into every validator

Math

SignaturePreventsReplaces
a divFloor b (Math.divFloor(a: BigInt, b: BigInt): BigInt)AR-1 rounding direction: the protocol’s share rounds down, explicitlya / b (truncates toward zero, rounds up for negatives)
a divCeil b (Math.divCeil(a: BigInt, b: BigInt): BigInt)AR-1: the user’s obligation rounds up, so no fraction leaks per transaction(a + b - 1) / b and other hand-rolled ceilings

An alphanumeric infix operator has the lowest precedence: total divCeil n * fee parses as total divCeil (n * fee). Parenthesize operands that are expressions.

The fail-fast form of every lookup

Reach for the OrFail form whenever the validator cannot continue without the value. Each one takes a message, so the failure names the check.

LookupFail-fast formFails when
Map entrymap.getOrFail(key, msg)key absent
Inline datum, typedout.datum.inlineOrFail[T](msg)datum is a hash or missing
Interval boundbound.finiteOrFail(msg)bound is infinite
Only elementlist.singleOrFail(msg), map.singleOrFail(msg)size is not one
Unique matchlist.findUniqueOrFail(p, msg)zero or two or more matches
Input by referencetx.findInputOrFail(ref, msg)no input spends ref
Continuing outputtx.findContinuingOutputOrFail(ownInput, msg)zero or several outputs at the exact address
Credential hashcred.scriptHashOrFail(msg) / cred.pubKeyHashOrFail(msg)wrong credential kind
Validity boundstx.validFromOrFail(msg) / tx.validToOrFail(msg)the range is unbounded on that side

Vocabulary. singleton builds a one-element collection (List.singleton(a), SortedMap.singleton(k, v)). singleOrFail extracts the only element of a size-one collection. findUniqueOrFail finds the one element that satisfies a predicate among many, and keeps scanning to prove there is no second. .head after filter is the anti-idiom for both.

Deprecated members

Deprecated members still compile and delegate to their replacement. The design-pattern callback change in the last row is different: it is in place, with no deprecated overload.

DeprecatedReplacementNote
findOwnInput(ref)findInput(ref)”Own” was a Plutus inheritance; these find any input
findOwnInputOrFail(ref, msg)findInputOrFail(ref, msg)direct scan, no Option
findOwnDatum(hash)findDatum(hash)
findOwnScriptOutputs(hash)findOutputsByScriptHash(hash)payment credential only; not for the continuing output (AU-4)
findOwnInputsByCredential(cred)findInputsByCredential(cred)payment credential only
findOwnOutputsByCredential(cred)findOutputsByCredential(cred)payment credential only; not for the continuing output (AU-4)
findOwnInputs(pred)tx.inputs.filter(pred)one-liner
findOwnOutputs(pred)tx.outputs.filter(pred)one-liner
getValidityStartTimevalidFromOrFail(msg)returned 0 for an unbounded range (TI-1)
Utils.getAdaFromOutputs(outs)valuePaidTo(addr).getLovelacesummed lovelace only (VP-2); the replacement sums the whole Value
Utils.getAdaFromInputs(ins)valueSpentFrom(addr).getLovelacesame
List.single(a)List.singleton(a)one constructor name across all four collections
PairList.single(a, b)PairList.singleton(a, b)same
Design-pattern Boolean callbacks (UtxoIndexer, StakeValidator, TransactionLevelMinterValidator)=> Unit callbacks that require / fail with their own messagechanged in place, no overload. Hazard: a Boolean lambda still compiles against the Unit parameter (value discard) and then checks nothing. Grep every callback and wrap its condition in require(..., message)

Worked example: the vesting validator

scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingValidator.scala, the spend branch before and after migration. Five operations do the work of nine lines of scaffolding, and every check that used to be implicit now fails with its own message.

Before

val ownInput = txInfo.findOwnInputOrFail(txOutRef).resolved val contractAddress = ownInput.address require( txInfo.findOwnInputsByCredential(contractAddress.credential).length === BigInt(1), MultipleVestingInputs ) val contractOutputs = txInfo.findOwnOutputsByCredential(contractAddress.credential) val txEarliestTime = txInfo.getValidityStartTime // 0 when the range has no lower bound // ... require(contractOutputs.length === BigInt(1), NotExactlyOneContractOutput) val contractOutput = contractOutputs.head require(contractOutput.address === ownInput.address, ContinuingAddressMismatch) require( contractOutput.value === ownInput.value - Value.lovelace(requestedAmount), ContinuingValueMismatch ) require(contractOutput.datum === OutputDatum.OutputDatum(vestingDatum), InvalidDatum)

After

val ownInputInfo = txInfo.findInputOrFail(txOutRef) val ownInput = ownInputInfo.resolved val ownCredential = ownInput.address.credential // One own input per transaction: one pass, fails on a second match (DS-1). txInfo.inputs.findUniqueOrFail( _.resolved.address.credential === ownCredential, MultipleVestingInputs ) val txEarliestTime = txInfo.validFromOrFail(NoValidityLowerBound) // fails, never 0 (TI-1) // ... // The unique output at the exact own input address, staking credential included (AU-4). val contractOutput = txInfo.findContinuingOutputOrFail(ownInputInfo, NotExactlyOneContractOutput) require( contractOutput.value === ownInput.value - Value.lovelace(requestedAmount), ContinuingValueMismatch ) require(contractOutput.hasInlineDatum(vestingDatum), InvalidDatum)

The vesting formula in the same file uses the rounding operation: vestingDatum.initialAmount * elapsed divFloor vestingDatum.duration.

See Also

Last updated on