The structure of a Plinth smart contract
showed the shape of a project and the PlutusTx.compile splice that freezes
on-chain logic into a value. This page is about the code that goes inside that
splice: what writing Plinth actually looks like, and how it differs from ordinary
Haskell. It is an overview, not a reference — the Plinth user
guide is the complete guide to the language.
A subset of Haskell
Plinth is not a separate language with its own syntax. It is Haskell, written
in ordinary modules and type-checked by GHC, restricted to the part the
compiler can translate to
UPLC. You keep the things that have a
Plutus Core counterpart — algebraic data types, pattern matching,
higher-order functions, recursion, parametric polymorphism, and type classes
— and give up the things that do not, such as IO, and arbitrary library
code that was never written to be compiled on-chain. As a small but telling
example, on-chain integers are arbitrary-precision Integer (which maps to the
UPLC integer builtin), never the machine-word Int. Differences from
Haskell catalogs the
unsupported features, the errors they trigger, and the reasons behind them.
The Plinth prelude
The standard Haskell Prelude is full of functions that cannot go on-chain, so
Plinth modules use PlutusTx.Prelude instead — either by enabling
NoImplicitPrelude and importing it unqualified, or (as the project template
does) importing it qualified:
import PlutusTx.Prelude qualified as PlutusTx
PlutusTx.Prelude mirrors the familiar names — Eq, Ord, &&, the
arithmetic operators, list functions — but every one is built from Plinth
primitives that compile down to UPLC builtins.
So you still write ==, &&, +, and map; they just resolve to the on-chain
versions rather than the standard library’s.
Crossing the boundary: Data, makeIsDataIndexed, makeLift
A datum, redeemer, or script parameter crosses between Haskell and the ledger as
Data, so your Haskell types need encodings.
Two Template Haskell helpers derive them:
-
makeIsDataIndexedderives theToData/FromDatainstances that convert a type to and fromData, with explicit constructor indices so the on-chain encoding is stable across changes to the source:data AuctionRedeemer = NewBid Bid | Payout PlutusTx.makeIsDataIndexed ''AuctionRedeemer [('NewBid, 0), ('Payout, 1)] -
makeLiftderives aLiftinstance, letting an ordinary Haskell value be lifted into a script as a constant. This is what makes parameterised scripts work: the off-chain code applies a parameter withliftCode(see the structure page).
How those encodings work, the choice between sum-of-products and Data-backed
representations, and the schema-aware variant makeIsDataSchemaIndexed are
covered in Data representation.
What a validator looks like
The entry point the ledger actually calls is untyped: it takes
BuiltinData and returns BuiltinUnit, signalling success by returning and
failure by erroring. You rarely write at
that level. Instead you write a typed function over real Haskell types and wrap
it. From the template (PlutusV3, where the single argument is the script context
that now carries the redeemer):
auctionTypedValidator :: AuctionParams -> ScriptContext -> Bool
auctionTypedValidator params ctx = ...
auctionUntypedValidator :: AuctionParams -> BuiltinData -> BuiltinUnit
auctionUntypedValidator params ctx =
PlutusTx.check (auctionTypedValidator params (PlutusTx.unsafeFromBuiltinData ctx))
Reading the wrapper: unsafeFromBuiltinData decodes the Data into your typed
ScriptContext; the typed validator returns a Bool; and check :: Bool ->
BuiltinUnit turns False into an error (so the script fails) and True into
unit (so it passes). It is auctionUntypedValidator — the untyped entry
point — that gets quoted into the compile splice. A minting policy is
written the same way: a typed function returning Bool, wrapped with check; it
simply receives no datum.
(Earlier ledger languages differ only in the wrapper’s shape: PlutusV1 and V2
pass the datum, redeemer, and context as separate BuiltinData arguments,
whereas PlutusV3 passes a single context argument.)
Unfoldings and INLINEABLE
The plugin compiles the one function named in the compile quote, following
every definition it references. For a referenced definition in another module to
be available, GHC must keep its unfolding (its GHC Core definition) in the
interface file.
You do not need {-# INLINEABLE #-} pragmas for this:
- plinthc marks each definition that has no inline pragma of its own as
INLINEABLE, so GHC keeps its unfolding. - The
base,ghc-primandghc-bignumlibraries that come with plinthc have the unfoldings of all their definitions. - In plinthc,
-O0does not set-fomit-interface-pragmasand-fignore-interface-pragmas, which remove unfoldings. Thus you can build Plinth code with-O0.
Only a {-# NOINLINE #-} or {-# OPAQUE #-} pragma hides a definition of your
code from the plugin.
Code from the Plinth template and from the Plutus libraries still has
INLINEABLE pragmas. They are not necessary, but they do no harm.
Sharing with off-chain code
Because all of this is Haskell, the datatypes and their derived ToData /
FromData instances are shared directly with the
off-chain code in the same project: the
off-chain side builds datums and redeemers from the very same types the validator
decodes, so the two halves cannot drift out of agreement. Data
representation covers this sharing
in detail.
Further reading
- Differences from Haskell — the unsupported features, the errors they trigger, and why.
- The structure of a Plinth smart contract
— where this code sits in a project, and the
compilesplice. - From Plinth to the chain — what the compiled validator becomes.
- The UPLC language — the builtins and
Datatype the prelude and encodings map onto. - Contract blueprints (CIP-57) —
the schemas
makeIsDataSchemaIndexedproduces. - Plinth user guide — the complete language reference.