| CIP | 152 | |
|---|---|---|
| Title | Modules in UPLC | |
| Status | Proposed | |
| Category | Plutus | |
| Authors |
|
|
| Implementors | ||
| Discussions | ||
| Created | 2024-11-12 | |
| License | CC-BY-4.0 |
Cardano scripts are limited in complexity by the fact that each script must be supplied in one transaction, whether the script is supplied in the same transaction in which it is used, or pre-loaded onto the chain for use as a reference script. This limits script code size, which in turn limits the use of libraries in scripts, and ultimately limits the sophistication of Cardano apps, compared to competing blockchains. The script size limit is an aspect of Cardano that script developers commonly complain about.
This CIP addresses this problem directly, by allowing reference inputs to supply 'modules', which can be used from other scripts (including other modules), thus allowing the code of a script to be spread across many reference inputs. The 'main specification' requires no changes to UPLC, PTLC, PIR or Plinth; only a 'dependency resolution' step before scripts are run. Many variations are described for better performance, including some requiring changes to the CEK machine itself.
Higher performance variations will be more expensive to implement; the final choice of variations should take implementation cost into account, and (in some cases) may require extensive benchmarking.
Cardano scripts are currently subject to a fairly tight size limit, deriving from the overall limit on transaction size; even when a script is supplied in a reference UTxO, that UTxO must itself be created by a single transaction, which is subject to the overall transaction size limit. Competing blockchains suffer from no such limit: on the Ethereum chain, for example, contracts can call one another, and so the code executed in one transaction may come from many different contracts, created independently on the blockchain--each subject to a contract size limit, but together potentially many times that size. This enables more sophisticated contracts to be implemented; conversely, on Cardano, it is rather impractical to implement higher-level abstractions as libraries, because doing so will likely exceed the script size limit. This is not just a theoretical problem: complaints about the script size limit are commonly made by Cardano contract developers.
Thus the primary goal of this CIP is to lift the limit on the total amount of code run during a script execution, by allowing part of the code to be provided in external modules. By storing these modules on the blockchain and providing them as reference UTxOs, it will be possible to keep transactions small even though they may invoke a large volume of code.
Once scripts can be split into separate modules, then the question immediately arises of whether or not the script and the modules it imports need to be in the same language. Today there are many languages that compile to UPLC, and run on the Cardano blockchain. Ideally it should be possible to define a useful library in any of these languages, and then use it from all of them. A secondary goal is thus to define a module system which permits this, by supporting cross-language calls.
Note that many languages targetting UPLC already support modules. In particular, Plinth already enjoys a module system, namely the Haskell module system. This already enables code to be distributed across several modules, or put into libraries and shared. Indeed this is already heavily used: the DJED code base distributes Plinth code across 24 files, of which only 4 contain top-level contracts, and the others provide supporting code of one sort or another. Thus the software engineering benefits of a module system are already available. The disadvantage of this approach is that all the code is combined into one script, which can easily exceed the size limit as a result. Indeed, the DJED code base also contains an implementation of insertion sort in Plinth, with a comment that a quadratic algorithm is used because its code is smaller than, for example, QuickSort. There is no clearer way to indicate why the overall limit on code size must be lifted.
Ethereum contracts are not directly comparable to Cardano scripts; they correspond to both the on-chain and the off-chain parts of Cardano contracts, so one should expect Ethereum contracts to require more code for the same task, since in Cardano only the verifiers need to run on the chain itself. Nevertheless, it is interesting to ask whether, and how, the need for modules has been met in the Ethereum context.
Solidity does provide a notion of 'library', which collects a number of reusable functions together. Libraries can be 'internal' or 'external'--the former are just compiled into the code of client contracts (and so count towards its size limit), while the latter are stored separately on the blockchain.
There is a problem of trust in using code supplied by someone else:
the documentation for the ethpm package manager for Ethereum warns
sternly
you should NEVER import a package from a registry with an unknown or untrusted owner
It seems there is only one trusted registry, and it is the one
supplied as an example by the developers of ethpm. In other words,
while there is a package manager for Ethereum, it does not appear to
be in use.
This is not to say that code is never shared. On the contrary, there
is an open source repo on github called OpenZeppelin which appears
to be heavily used. It provides 264 Solidity files, in which 43
libraries are declared (almost all internal). It seems, thus, that
libraries are not the main way of reusing code in Solidity; rather it
is by calling, or inheriting from, another contract, that code reuse
primarily occurs.
A small study of 20 'verified' contracts running on the Ethereum chain (verified in the sense that their source code was provided) showed that
- 55% of contracts consisted of more than one module
- 40% of contracts contained more than one 'application' module
- 55% of contracts imported
OpenZeppelinmodules - 10-15% of contracts imported modules from other sources
- 5% of contracts were simply copies of
OpenZeppelincontracts
Some of the 'other' modules were provided to support specific protocols; for example Layr Labs provide modules to support their Eigenlayer protocol for re-staking.
A sample of 20 is too small to draw very strong statistical conclusions, but we can say that the 95% confidence interval for contracts to consist of multiple modules is 34-74%. Thus code sharing is clearly going on, and a significant number of transactions exploit multiple modules. We may conclude that there is a significant demand for modules in the context of smart contracts, even if the total contract code still remains relatively small.
This CIP provides the simplest possible way to split scripts across
multiple UTxOs; essentially, it allows any closed subterm to be
replaced by its hash, whereupon the term can be supplied either as a
witness in the invoking transaction, or via a reference script in that
transaction. To avoid any change to the syntax of UPLC, hashes are
allowed only at the top-level (so to replace a deeply nested subterm
by its hash, we need to first lambda-abstract it). This also places
all references to external terms in one place, where they can easily
be found and resolved. Thus we need only change the definition of a
Script; instead of simply some code, it becomes the application of
code to zero or more arguments, given by hashes.
Currently, the definition of “script” used by the ledger is (approximately):
newtype Script = Script ShortByteString
We change this to:
newtype CompleteScript = CompleteScript ShortByteString
newtype Arg = ScriptArg ScriptHash
data Script =
ScriptWithArgs { head :: CompleteScript, args :: [Arg] }
-- hash of a Script, not a CompleteScript
type ScriptHash = ByteString
Scripts in transactions, and on the chain, are represented in this way, with dependencies that must be supplied in a transaction using the script. During phase 2 verification we need to resolve the arguments of each script before running it:
resolveScriptDependencies
:: Map ScriptHash Script
-> Script
-> Maybe CompleteScript
resolveScriptDependencies preimages = go
where
go (ScriptWithArgs head args) = do
argScripts <- traverse lookupArg args
pure $ applyScript head argScripts
where
lookupArg :: Arg -> Maybe CompleteScript
lookupArg (ScriptArg hash) = do
script <- lookup hash preimages
go script
The preimages map is the usual witness map constructed by the ledger,
so in order for a script hash argument to be resolved, the transaction
must provide the pre-image in the usual way. Note that arguments are
mapped to a Script, not a CompleteScript, so the result of looking
up a hash may contain further dependencies, which need to be resolved
recursively. A transaction must provide witnesses for all the
recursive dependencies of the scripts it invokes.
The only scripts that can be run are complete scripts, so the type of
runScript changes to take a CompleteScript instead of a Script.
With the design above, if any script hash is missing from the preimages,
then the entire resolution fails. As an alternative, we might replace
missing subterms by a dummy value, such as builtin unit, thus:
resolveScriptDependencies
:: Map ScriptHash Script
-> Script
-> CompleteScript
resolveScriptDependencies preimages = go
where
go (ScriptWithArgs head args) =
applyScript head (map lookupArg args)
where
lookupArg :: Arg -> CompleteScript
lookupArg (ScriptArg hash) = do
case lookup hash preimages of
Nothing -> builtin unit
Just script -> go script
This would allow transactions to provide witnesses only for script arguments which are actually used in the calls that the transaction makes. This may sometimes lead to a significant reduction in the amount of code that must be loaded; for example, imagine a spending verifier which offers a choice of two encryption methods, provided as separate script arguments. In any call of the verifier, only one encryption method will be required, allowing the other (and all its dependencies) to be omitted from the spending transaction.
The goal of this variation is to eliminate the cost of evaluating
scripts, by converting them directly to values (in linear time). Since
UPLC runs on the CEK machine, this means converting them directly into
the CekValue type, without any CEK machine execution. To make this
possible, the syntax of scripts is restricted so that those parts that
would be evaluated during an application to the script arguments are
already (UPLC) values. That is, script code is syntactically
restricted to explicit λ-expressions with one λ per ScriptArg,
followed by a syntactic value. (Values are constants, variables,
built-ins, λ-abstractions, delayed terms, and SoP constructors whose
fields are also values).
This means that every script must take the form
λA1.λA2....λAn.<value>, where n is the number of ScriptArgs
supplied. Now, since variables in CompiledCode are de Bruijn indices
then the n λs can be omitted from the representation--we know how
many there must be from the number of ScriptArgs, and the names
themselves can be reconstructed.
There must be a dynamic check that the code of each script really is of this form, but this check can be built into deserialization, and thus need cost very little.
Scripts in this restricted form can be mapped directly into CEK
values, without any CEK-machine evaluation steps. In pseudocode:
scriptCekValue
:: Map ScriptHash CekValue
-> Script
-> CekValue
scriptCekValue scriptValues (ScriptWithArgs head args) =
cekValue (Env.fromList [case lookup h scriptValues of
Just v -> v
Nothing -> vbuiltin unit
| ScriptArg h <- args])
(deserialize (getPlc head))
That is, a script is turned into a value by creating a CEK machine
environment from the values of the ScriptArgs, and converting the
body of the script (a syntactic value) in a CekValue in that
environment.
This pseudocode follows the 'lazy loading' variation; an easy variation treats not finding a script hash as an error.
Syntactic values are turned into CekValues by the following
function, which is derived by simplifying computeCek in
UntypedPlutusCore.Evaluation.Machine.Cek.Internal, and restricting it
to syntactic values.
cekValue
:: Env
-> NTerm
-> CEKValue
cekValue env t = case t of
Var _ varname -> lookupVarName varName env
Constant _ val -> VCon val
LamAbs _ name body -> VLamAbs name body env
Delay _ body -> VDelay body env
Builtin _ bn ->
let meaning = lookupBuiltin bn ?cekRuntime in
VBuiltin bn (Builtin () bn) meaning
Constr _ i es ->
VConstr i (foldr ConsStack EmptyStack (map (cekValue env) es)
_ -> error
Converting a syntactic value to a CekValue does require traversing it, but the traversal stops at λs and delays, so will normally traverse only the top levels of a term.
Finally, if preimages is the Map ScriptHash Script constructed from
a transaction, then we may define
scriptValues = Map.map (scriptCekValue scriptValues) preimages
to compute the CekValue of each script.
Scripts are then applied to their arguments by building an initial CEK machine configuration applying the script value to its argument value.
Note that this recursive definition of scriptValues could potentially allow an
attacker to cause a black-hole exception in the transaction validator,
by submitting a transaction containing scripts with a dependency
cycle. However, since scripts are referred to by hashes, then
constructing such a transaction would require an attack on the hash
function itself... for example a script hash h and values for head
and args such that
h = hash (Script head (h:args))
We assume that finding such an h is impossible in practice; should
this not be the case, or if we should wish to defend against an
attacker with the resources to find such an attack on the hash
function, then we must build a script dependency graph for each
transaction and check that it is acyclic before evaluating the scripts
in this way.
Converting Scripts to CekValues does require a traversal of all
Scripts, and the top level of each Script value. This is linear
time in the total size of the scripts, though, and should be
considerably faster than doing the same evaluation using CEK machine
transitions. The conversion can be done once for a whole
transaction, sharing the cost between several scripts if they share
modules (such as frequently used libraries). So costs should be
charged for the whole transaction, not per script. The most accurate
cost would be proportional to the total size of values at the
top-level of scripts. A simpler approach would be to charge a cost
proportional to the aggregated size of all scripts, including
reference scripts--although this risks penalizing complex scripts with
a simple API.
The CEK implementation does not, today, expose an API for starting
evaluation from a given configuration, or constructing CekValues
directly, so this variation does involve significant changes to the
CEK machine itself.
Many modules define recursive functions at the top-level. In this
variation, the innermost body of a script is further restricted to the
form λSelf.<value>, and resolveScriptDependencies applies an
implicit fix to the script body, after supplying the script
arguments. Like the other λs binding script arguments, the λSelf.
need not appear in the actual representation; we know it has to be
there so we can just store the body of the λ. When a script is
evaluated, the value of the script is just added to the environment in
the same way as the script arguments. The script can then refer to
its own value using Self.
This variation is a less-restrictive version of 'value scripts'. As in
the former case, we restrict scripts syntactically to explicit
λ-expressions binding the script arguments, but we do not restrict the
script body proper to be a syntactic value. As in the former case, the
λs need not be present in the Script representation, because their
number is known from the number of script arguments, and the bound
variables are deBruijn indices.
In this variation, script bodies cannot be converted to CekValues
using cekValue; we actually have to run the CEK machine to evaluate
them. This requires extending the API of the CEK machine, to support
evaluating a UPLC term in a given environment, and returning a
CekValue, rather than a discharged NTerm, because discharging a
CekValue loses sharing. Losing sharing is unacceptable because it
introduces a potentially exponential space cost for acyclic
structures, and leads to non-termination in the case of cyclic
structures (created by 'Module-level recursion').
The implementation of the CEK machine currently always discharges values before returning them; the core loop of the machine will need to be modified to change this.
Since script bodies must be evaluated by running the CEK machine, then it is possible to exceed the execution unit budget at any point during the script evaluation. The budget must be checked during these evaluations, and the budget for evaluating each script will depend on the actual costs of evaluating all the previous ones.
To avoid circular dependencies, the scripts must be topologically sorted before evaluation, so that no earlier script depends on a later one. Topological sorting is linear time in the total number of scripts and script arguments.
It is still possible to write a recursive definition of the
scriptValues, so that each script can depend on the same map, but
care is needed to avoid circular dependencies for the reasons
explained above.
The following variations make heavy use of tuples which in practice
could grow quite large--tuples of modules, and modules as tuples of
exports. These variations only make sense if projection of a component
from a tuple is efficient, and in particular, constant time,
independent of the tuple size. At present, tuples are represented
using the SoP extension (CIP-85) as constr 0 x1...xn, but the only
way to select the ith component is using
case t of (constr 0 x1...xi...xn) -> xi
which takes time linear in the size of the tuple to execute, because
all n components need to be extracted from the tuple and passed to
the case branch (represented by a function).
We assume below that there is an expression proj i t in UPLC, where
i is a constant, which efficiently extracts the ith component from
tuple t. There are several ways this could be implemented:
proj i tcould be added as a new construct to UPLC, together with extensions to the CEK machine to evaluate it.projcould be added as a new built-in to UPLC--probably a smaller change to the implementation, but less efficient (becauseiwould be an argument needing evaluation, rather than a constant integer in the AST), and problematic to add to TPLC (because typing it requires dependent types).- represent 'tuples' in this context as functions from indices to
components, so
(x,y,z)would be represented asThis requires support in UPLC for pattern-matching on integers in constant time, which is not implemented right now, but is on the horizon. It would also need dependent types to be typed, and so cannot be added to Plinth, PIR or PTLC.λi. case i of 0 -> x 1 -> y 2 -> z
In the sections below we just use tuples and the notation proj i t,
on the assumption that an implementation is chosen and deployed.
In the main specification in this CIP, script code is a curried function of the script arguments; that is, imported modules are supplied to scripts as individual arguments. In this variation, the script code is instead an uncurried function of the script arguments, which are tupled together to be passed to the script code.
This variation only makes sense if the 'value scripts' variation is
also adopted, and places an additional syntactic restriction on script
code: it must be of the form λMods.e, and all occurrences of Mods
in e must be of the form proj i Mods for some i. That is, it is
impossible to refer to the whole tuple of modules; scripts can refer
to only one module at a time.
To avoid additional overheads for scripts without arguments, we
redefine the Script type as follows:
data Script =
CompleteScript CompleteScript
| ScriptWithArgs { head :: CompleteScript, args :: [Arg] }
Here the CompleteScript alternative is used for scripts without
script arguments; such scripts are not applied to a tuple of modules
before use, and so need not be of the form λMods.e. (This is only
needed because a 'function of zero arguments' has no overhead, while a
function of a zero-tuple does).
In the 'tuples of modules' variation, each script is paremeterised on a tuple of modules, and fetches the modules when needed by projecting out a component of the tuple. In the 'global module environment' subvariation, all the modules are placed in one tuple, from which scripts fetch the modules they need.
The global module environment is constructed for the transaction as a
whole, containing all the scripts provided by the transaction. It
follows that the same module may end up in different components in
different transactions. Scripts refer to other modules via references
of the form proj i Mods, where Mods is the variable bound to the
tuple of modules. Before scripts are run, these references must be
replaced by proj j Mods, where j is the index of the corresponding
module in the global module environment. Thus it is necessary to
traverse the code of all the scripts, relocating module references to
refer to the global module environment instead. One this is done, then all
the script values can refer to the same tuple of modules.
In this subsubvariation, the (single) tuple of modules is passed (as a new implicit parameter) directly to the CEK machine, instead of being passed as a parameter in UPLC. Consequently it cannot be accessed as a UPLC variable; new UPLC constructs are needed instead. Since references to the global tuple of modules always refer to a particular module, then it suffices to add a construct of the form
data Term name uni fun ann = .. | ModuleRef Int
such that ModuleRef i evaluates to the ith component of the global
module tuple.
Once again, the scripts provided in a transaction must refer to script arguments using an index into the script's own script arguments; before execution these indices must be replaced by the corresponding indices in the global module environment, necessitating a traversal of the script code to prepare it for execution.
In this subvariation, we distinguish between validation scripts and
scripts representing modules; the latter are subject to an additional
syntactic restriction that the script body must be a tuple. We change
the Script type accordingly
data Script = ValidatorScript CompiledCode [ScriptArg]
| ModuleScript CompiledCode [ScriptArg]
so that the deserializer can easily check the new syntactic
restriction. Scripts used as ScriptArgs may only be of the
ModuleScript form (this requires a dynamic check). The idea is that
a module provides a number of exports, which are the components of the
tuple. (Again, special cases for an empty list of script arguments
can be included in this type if desired).
In addition, expressions M referring to modules (of the form proj j Mods) may only appear in contexts of the form proj i M, projecting
out one of the module exports. We call these terms 'export
references'.
With this restriction, a tuple of modules is now a tuple of tuples,
and the effect of the subvariation is to flatten that into a tuple of
exports instead. Every module export is assigned an index in the
resulting tuple, and the scripts must be preprocessed before execution
to replace the indexes in every export reference by the corresponding
index in the tuple--so proj i (proj j Mods) becomes proj k Mods
for k the index of the ith export of the jth module.
In the case of modules which are omitted from the transaction (see
'lazy loading'), the export references proj i (proj j Mods) should
be replaced by builtin unit. This is either the correct value, or
will cause a run-time type error (and thus verification failure) if
the value is used.
In the case of a global module environment, then since the placement
of modules in a global tuple depends on all the modules used in a
transaction, and since some of the scripts used by a transaction are
taken from pre-existing reference UTxOs, then this preprocessing
cannot be done in advance; it must be done during script verification
of the transaction. This subvariation can be combined with 'module
environment built into the CEK machine', in which case the export
references are replaced by suitable ModuleRef k expressions as
before. It does not change the CompiledCode stored in scripts; it
only affects the way that code is prepared for execution.
If the module environment is local for each script, then the
preprocessing can be done at compile-time--and scripts on the chain
can be stored as functions of one big tuple, containing all the
exports from modules imported into the script. There is a subtlety in
the case of lazy loading: resolveScriptDependencies becomes
responsible for creating a tuple with one entry per export, even for
modules which are not provided in the transaction. For this to be
possible, resolveScriptDependencies needs to know how many exports
the imported module has, even without access to its body. To make this
possible we can extend the ScriptArg type:
data ScriptArg = ScriptArg ScriptHash Int
In the case of a module which is supplied, there should be a dynamic
check that the number of components agrees with the ScriptArg; in
the case of a module which is not supplied in the transaction,
resolveScriptDependencies should just add this number of builtin units to the tuple passed to the script. Note that an attacker could
force the transaction verifier to allocate a very large amount of
memory by supplying a very large integer here, in a transaction that
does not include the module itself--and so might be cheap. To prevent
this, transaction fees must take the values of these integers into
account; it may also be sensible to place an absolute upper limit on
the number of exports from a module.
The last two subvariations above both require a traversal of all the script code in a transaction (including the code fetched from reference scripts) to adjust module or export references. If they are adopted, transaction fees should be increased by an amount linear in the total script size to pay for this traversal.
No change is needed in TPLC.
No change is needed in PIR.
The Plinth modules introduced in this CIP bear no relation to Haskell modules; their purpose is simply to support the module mechanism added to UPLC. They are first-class values in Haskell.
Just as we introduced a distinction in UPLC between CompleteScript
and Script, so we introduce a distinction in Plinth between
CompiledCode a (returned by the Plinth compiler when compiling a
term of type a), and Module a representing a top-level Script
with a value of type a.
newtype Module a = Module {unModule :: Mod}
newtype ModArg = ModArg ScriptHash
data Mod = forall b. Mod{ modCode :: Maybe (CompiledCode b),
modArgs :: Maybe ([ModArg]),
modHash :: ScriptHash }
Here the modArgs correspond to the ScriptArgs in the UPLC case,
and the modHash is the hash of the underlying Script. The type
parameter of Module a is a phantom parameter, just like the type
parameter of CompiledCode a, which tells us the type of value which
the application of the modCode to the modArgs represents.
We can convert any ScriptHash into a module:
fromScriptHash :: ScriptHash -> Module a
fromScriptHash hash = Module Nothing Nothing hash
and we can convert any CompiledCode into a module:
makeModule :: CompiledCode a -> Module a
makeModule code = Module (Just (Mod code)) (Just []) ...compute the script hash...
We also need a way to supply an imported module to a Module:
applyModule :: Module (a->b) -> Module a -> Module b
applyModule (Module (Mod (Just code) (Just args) _)) m =
Module (Mod (Just code) (Just (args++[modHash m])) ...compute the script hash...)
As in UPLC, the intention is that scripts that import modules be
written as lambda-expressions, and the imported module is then
supplied using applyModule. No change is needed in the Plinth
compiler to support this mechanism.
Note that only a Module containing code and an argument list can
have the argument list extended by applyModule; this is because the
ScriptHash of the result depends on the code and the entire list of
arguments, so it cannot be computed for a module that lacks either of
these.
It is Module values that would then be serialised to produce scripts
for inclusion in transactions.
In the 'unboxed modules' variation we need to distinguish two kinds of
scripts, scripts which define modules, and scripts which define
validators. In Plinth, this distinction can be made in the types, by
turning the Module type into a GADT with an extra parameter, of type
data ScriptType = ModuleScript | ValidatorScript
applyModule would be given a more restrictive type:
applyModule :: Module s (a->b) -> Module ModuleScript a -> Module s b
thus ensuring that only scripts representing modules are passed as script arguments.
Plutus ledger language version is what "Plutus V1", "Plutus V2", "Plutus V3" refer to. These are not distinct programming languages; the primary difference lies in the arguments the script receives from the ledger, and the value it returns1. Plutus V1, V2 and V3 can therefore be understood as type signatures, in the sense that they each represent a subset of UPLC programs with specific types. Any UPLC program that matches the expected argument and return types can be considered and used as a Plutus V1, V2 or V3 script. A new ledger era is the primary reason for introducing a new ledger language version, though technically there can be cases where a new ledger language version is necessary without a new ledger era.
Currently each script on-chain is tagged with a specific ledger language version - V1, V2, V3 or native script - and this version tag is a component of the script hash. A logical approach, therefore, is to continue doing so for module scripts, and require that a validator script and all modules it references must use the same ledger language version; failure to do so leads to a phase-1 error.
A different approach is to distinguish between validator scripts and module scripts by applying version tags only to validator scripts. Module scripts are untagged and can be linked to any validator script. This makes module scripts more reusable, which is advantageous because in most cases, a UPLC program has the same semantics regardless of the ledger language version.
This is, however, not always the case because a few builtin functions have multiple semantic variants, and the variant used may differ depending on the ledger language version. Nonetheless, if a module script depends on a particular ledger language version to work correctly, this requirement can be communicated through alternative means, e.g., as a piece of metadata in a module script registry.
Another drawback of untagged modules is that untagged modules will be a new concept that doesn't currently exist, and as a result, modules will not be usable in Plutus V1 through V3, and can only be used from Plutus V4 onwards.
Plutus Core version is the usual sense of version pertaining to programming languages - in this instance the Plutus Core language. So far there have been two Plutus Core versions: 1.0.0 and 1.1.0. 1.1.0 adds sums-of-products to the language by introducing two new AST node types: Case and Constr. See CIP-85 for more details. Each UPLC program is tagged with a Plutus Core version (where as for ledger language versions, only scripts that exist on-chain, i.e., stored in UTXOs, are tagged with ledger language versions).
UPLC programs with different Plutus Core versions are incompatible and cannot be combined, and therefore, a validator script and all modules it references must share the same Plutus Core version; otherwise it is a phase-1 error.
Here we use the term "script" to refer to either a validator script (which needs to be run to validate a transaction) and a module script (which serves as a dependency for other scripts). Both validators and modules can reference other modules.
The feature proposed in this CIP can only be released in a new ledger era. As such, it is anticipated that it will be released alongside the next ledger era - the Dijkstra era.
Whether this feature can be used in existing Plutus ledger language versions (V1 through V3) depends on which of the options described in subsection Plutus Ledger Language Versions (i.e., tagged or untagged modules) is chosen. If tagged modules are adopted, the feature will be available across all Plutus language versions (V1 through V4) starting at the hard fork that introduces the Dijkstra era. If untagged modules are adopted, it will only be usable in Plutus V4, as explained in the subsection.
The bulk of the implementation effort lies on the Plutus side, including updates to plutus-ledger-api, updates to the CEK machine, costing and benchmarking, among others.
The specifics will depend on which of the various alternatives outlined in this CIP is selected.
The Plutus team aims to complete the implementation of the selected approach according to its specification, in time for the Dijkstra era.
On the ledger and cardano-api side, the effort required to support this feature is not as substantial as it may appear to be. This is because the ledger already supports reference inputs and reference scripts since the Babbage era, and this existing mechanism can largely be reused to accommodate module scripts. The processes of storing a module script in a UTXO and using it in a transaction are similar to storing and using a reference script.
The main difference between reference scripts and module scripts is that a module script is, like an object file, not directly runnable but must be linked with a validator to form a runnable script. To support this, the ledger and cardano-api will need to implement some changes. The specifics will slightly vary depending on which of the alternative approaches is chosen, but it will generally involve the following.
Currently, deserialising a script returns a ScriptForEvaluation, which contains a deserialised script, along with the original serialised script. The ledger has a PlutusRunnable newtype that wraps ScriptForEvaluation.
With the introduction of modules, deserialising a script no longer produces a runnable script unless it is a self-contained validator that doesn't use modules.
Otherwise, the module hashes it references must be resolved and the modules linked before the validator can be executed.
To do so, the plutus-ledger-api package can implement one of two options, depending on which is more suitable for the ledger:
-
Script deserialisation will be modified to return a new data type,
ScriptForLinking. It is similar toScriptForEvaluationexcept that the deserialized script is not necessarily a self-contained script and may be accompanied by a list of module hashes it needs.Then, a function
linkValidator :: Map ScriptHash ScriptForLinking -> ScriptHash -> LinkedScriptis provided that performs linking for a particular validator identified byScriptHash, whereLinkedScript ~ UPLC.Program DeBruijn DefaultUni DefaultFun ()is a fully linked script. -
Alternatively, the following function can be provided:
linkScripts :: Map ScriptHash SerialisedScript -> Map ScriptHash LinkedScript, which performs deserialisation and linking for all (validator and module) scripts in one go.
In either case, the ledger should ensure that each script (including validator script and module script) is deserialised and processed no more than once.
Moreover, for the transaction builder to decide which modules a validator refers to are used at runtime, plutus-ledger-api will also expose the following function:
getUsedModules ::
MajorProtocolVersion ->
EvaluationContext ->
-- | All scripts provided in a transaction
Map ScriptHash SerialisedScript ->
-- | Hash of the validator
ScriptHash ->
-- | Script arguments
[Data] ->
-- | Hashes of used module scripts
Set ScriptHashThe value type of the Map could instead be ScriptForLinking (i.e., deserialised script) rather than SerialisedScript.
This function is to be called by the code building transactions (e.g., Cardano.Api.Fees.makeTransactionBodyAutoBalance) to determine which modules are necessary to include in a transaction.
This CIP provides a minimal mechanism to split scripts across several transactions. 'Imported' modules are provided in the calling transaction and passed as arguments to the top-level script, and their identity is checked using their hash. In the main specification, the representation of modules is left entirely up to compiler-writers to choose--a module may be any value at all (although some of the variations do restrict the form). For example, one compiler might choose to represent modules as a tuple of functions, while another might map function names to tags, as Solidity does, and represent a module as a function from tags to functions. Each language will need to define its own conventions for module representations, and implement them on top of this low-level mechanism. For example, a typed language might represent a module as a tuple of exported values, and store the names and types of the values in an (off-chain) interface file. Clients could use the interface file to refer to exported values by name, and to perform type-checking across module boundaries.
This design does not support mutually recursive modules. Module recursion is sometimes used in languages such as Haskell, but it is a rarely-used feature that will not be much missed.
There is no a priori reason why script arguments need be written in the same high-level language as the script itself; thus this CIP supports cross-language calls. However, since different languages may adopt different conventions for how modules are represented, then some 'glue code' is likely to be needed for modules in different languages to work together. In the longer term, it might be worthwhile defining an IDL (Interface Definition Language) for UPLC, to generate this glue code, and enable scripts to call code in other languages more seamlessly. This is beyond the scope of this CIP; however this basic mechanism will not constrain the design of such an IDL in the future.
In Plinth, because the Module type is a phantom type, it is easy to
take code from elsewhere and turn it into a Module t for arbitrary
choice of t; this can be used to import modules compiled from other
languages into Plinth (provided a sensible Plinth type can be given to
them).
With the introduction of modules, scripts are no longer self-contained--they may depend on imported modules. This applies both to scripts for direct use, such as spending verifiers, and to scripts representing modules stored on the chain. A module may depend on imported modules, and so on transitively. An important question is when the identity of those modules is decided. In particular, if a module is replaced by a new version, perhaps fixing a bug, can existing code on the chain use the new version instead of the old?
The design in this CIP supports both alternatives. Suppose a module
A imports modules B and C. Then module A will be represented
as the lambda-expression λB.λC.A. This can be compiled into a
CompleteScript and placed on the chain, with an empty list of
ScriptArgs, as a reference script in a UTxO, allowing it to be used
with any implementations of B and C--the calling script must pass
implementations of B and C to the lambda expression, and can
choose them freely. We call this 'dynamic linking', because the
implementation of dependencies may vary from use to use. On the other
hand, if we want to fix the versions of B and C then we can
create a Script that applies the same CompleteScript to two
ScriptArgs, containing the hashes of the intended versions of B
and C, which will then be supplied by
resolveScriptDependencies. We call this 'static linking', because
the version choice for the dependency is fixed by the script. It is up
to script developers (or compiler writers) to decide between static
and dynamic linking in this sense.
On the other hand, when a script is used directly as a validator then
there is no opportunity to supply additional arguments; all modules
used must be supplied as ScriptArgs, which means they are
fixed. This makes sense: it would be perverse if a transaction trying
to spend a UTxO protected by a spending validator were allowed to
replace some of the validation code--that would open a real can of
worms, permitting many attacks whenever a script was split over
several modules. With the design in the CIP, it is the script in the
UTxO being spent that determines the module versions to be used, not
the spending transaction. That transaction does need to supply all
the modules actually used--including all of their dependencies--but it
cannot choose to supply alternative implementations of them.
Long-lived contracts may need upgrades and bug fixes during their lifetimes. This is especially true of contracts made up of many modules--every time a dependency is upgraded or receives a bug fix, the question of whether or not to upgrade the client contract arises. However, the problem of upgrading contracts securely is a tricky one, and exists whether or not modules are used. Therefore this CIP does not address this problem specifically: developers should use the same mechanisms to handle upgrades of scripts with dependencies, as they use to upgrade scripts without dependencies. The only thing we note here is that the need for upgrades is more likely to arise when a script depends on many others, so it is more important to be prepared for it. Note that, because a script contains the hashes of its dependencies, no 'silent' upgrade can occur: the hash of a script depends on all of its code, including the code of its dependencies, so any change in a dependency will lead to a change in the script hash.
The 'lazy loading' variation in the specification section above permits fewer modules to be supplied in a transaction. Dependency trees have a tendency to grow very large; when one function in a module uses another module, it becomes a dependency of the entire module and not just of that function. It is easy to imagine situations in which a script depends on many modules, but a particular call requires only a few of them. For example, if a script offers a choice of protocols for redemption, only one of which is used in a particular call, then many modules may not actually be needed. The variation allows a transaction to omit the unused modules in such cases. This reduces the size of the transaction, which need provide fewer witnesses, but more importantly it reduces the amount of code which must be loaded from reference UTxOs.
If a script execution does try to use a module which was not
provided, it will encounter a run-time type error and fail (unless the
module value was builtin unit, in which case the script will behave
as though the module had been provided).
To take advantage of this variation, it is necessary, when a transaction is constructed, to observe which script arguments are actually used by the script invocations needed to validate the transaction. The transaction balancer runs the scripts anyway, and so can in principle observe the uses of script arguments, and include witnesses in the transaction for just those arguments that are used.
To take advantage of 'lazy loading', it's necessary to identify reference scripts that are dynamically unused, when the scripts in a transaction run. The best place to do that is in a transaction balancer, which needs to run the scripts anyway, both to check that script validation succeeds, and to determine the number of execution units needed to run the scripts. We adopt the view that
A transaction balancer may drop reference inputs from a transaction, if the resulting transaction still validates
We call reference scripts which are not actually invoked during script verification 'redundant'; these are the reference scripts that can be removed by the balancer.
The simplest way for a balancer to identify redundant reference inputs is to try rerunning the scripts with an input removed. If script validation still succeeds, then that input may safely be removed. The advantages of this approach are its simplicity, and lack of a need for changes anywhere else in the code. The disadvantage is that transaction balancing may become much more expensive--quadratic in the number of scripts, in the worst case.
The reason for this is that removing one script may make others redundant too; for example if script A depends on script B, then script B may become redundant only after script A has been removed--simply evaluating script A may use the value of B, and scripts are evaluated when they are passed to other scripts, whether they are redundant or not. So if the balancer tries to remove B first, then script verification will fail--and so the balancer must try again to remove B after A has been shown to be redundant. Unless we exploit information on script dependencies, after one successful script removal then all the others must be revisited. Hence a quadratic complexity.
In the case of 'value scripts' this argument does not apply: evaluating a script will never fail just because a different script is not present. In this case it would be sufficient to traverse all the scripts once, resulting in a linear number of transaction verifications.
First the balancer analyses all the scripts and reference scripts in a
transaction, and builds a script dependency dag (where a script
depends on its ScriptArgs). Call the scripts which are invoked
directly in the transaction (as validators of one sort or another) the
root scripts.
Topologically sort the scripts according to the dependency relation; scripts may depend on scripts later in the order, but not earlier. Now, traverse the topologically sorted scripts in order. This guarantees that removing a later script in the order does not cause an earlier one to become redundant.
For each script, construct a modified dependency graph by removing the script concerned, and then 'garbage collecting'... removing all the scripts that are no longer reachable from a root. Construct a transaction including only the reference scripts remaining in the graph, and run script validation. If validation fails, restore the dependency graph before the modification. If validation succeeds, the script considered and all the 'garbage' scripts are redundant; continue using the now smaller dependency graph.
When all scripts have been considered in this way, then the remaining dependency graph contains all the scripts which are dynamically needed in this transaction. These are the ones that should be included in the transaction, either directly or as reference scripts.
The advantage of this approach is that only the code in the balancer needs to be changed. The disadvantage is that transaction balancing becomes more expensive: script verification may need to be rerun up to once per script or reference script. In comparison to the first approach above, this one is more complex to implement, but replaces a quadratic algorithm by a linear one.
The most direct way to determine that a script is not redundant is to observe it being executed during script verification. Unfortunately, the CEK machine, in its present form, does not make that possible. Thus an alternative is to modify the CEK machine so that a balancer can observe scripts being executed, and declare all the other scripts redundant. In comparison to the first two approaches, this is likely to be much more efficient, because it only requires running script verification once.
The modifications needed to the CEK machine are as follows:
CekValues are extended with tagged values, whose use can be
observed in the result of a run of the machine.
data CekValue uni fun ann =
...
| VTag ScriptHash (CekValue uni fun ann)
In the 'value script' variation, no expression resulting in a VTag
value is needed, because VTags will be inserted only by
resolveScriptDependencies. In other variations, a Tag constructor
must also be added to the NTerm type, to be added by
resolveScriptDependencies. In either case the version of
runScriptDependencies used in the balancer tags each value or
subterm derived from a ScriptHash h as VTag h ... (or Tag h ... in variations other than 'value scripts').
The CEK machine is parameterized over an emitter function, used for
logging. We can make use of this to emit ScriptHashes as they are
used. This allows the balancer to observe which ScriptHashes were
used.
Simply evaluating a tagged value, or building it into a
data-structure, does not use it in the sense we mean here: replacing
such a value with builtin unit will not cause a validation
failure. Only when such a value is actually used should we strip the
tag, emit the ScriptHash in the CekM monad, and continue with the
untagged value. This should be done in returnCek, on encountering a
FrameCases context for a tagged value, and in applyEvaluate when
the function to be applied turns out to be tagged, or when the
argument to a builtin turns out to be tagged.
Adding and removing tags must be assigned a zero cost in the
balancer, since the intention is that they should not appear in
transactions when they are verified on the chain. Thus a zero cost is
required for the balancer to return accurate costs for script
verification on the chain. On the other hand, if these operations do
reach the chain, then they should have a high cost, to deter attacks
in which a large number of tagging operations are used to keep a
transaction verifier busy. This can be achieved by adding a BTag
step kind to the CEK machine, a cekTagCost to the
CekMachineCostsBase type, and modifying the balancer to set this
cost to zero during script verification.
The advantage of this approach is that it only requires running each script once in the balancer, thus reducing the cost of balancing a transaction, perhaps considerably. The disadvantage is that it requires extensive modifications to the CEK machine itself, a very critical part of the Plutus infrastructure.
Another way to observe script uses without modifying the CEK machine
is to wrap them in Delay and force them at the point of use. The
balancer can then insert trace output of the script hash just inside
the Delay, and so observe which scripts are actually forced during
script execution.
The difficulties with this approach arise from the fact that delayed
closures must be explicitly forced in UPLC; this does not 'just
happen' when a delayed value is used. This means that corresponding
Force operations must also be added to scripts, and the question is:
who does this, and if it is to be done automatically, then how?
One possibility is that it is the developer's responsibility to force
script arguments at the point of use--that is, that the Force
operations needed would be written by the human programmer. It follows
that they would always be part of the script, even when running on
the chain, and so even on the chain script arguments would need to be
delayed (even if no trace output would be needed). This would increase
code size a little, and impose a force-delay overhead on every
cross-module reference, which is probably not acceptable.
The alternative is to have the balancer insert corresponding Force
operations, as well as the Delays. A simple way to do so would be
to add a Force around every use of a variable corresponding to a
script argument--under the 'value scripts' syntactic restriction these
variables are easy to identify. These modifications would not be made
during normal script verification, which might therefore cost less--or
more--than the modified balancer run. The balancer would thus need to
perform script verification twice: once with Delay and
Forceinserted to determine redundant scripts, and then a second time
(with redundant scripts removed) to determine the actual cost on the
chain.
The bigger problem with this approach, though, is that it will
overestimate the set of used scripts, leading to more scripts being
used in a transaction, and thus potentially exponentially more
expensive transactions. The reason for the overestimation is that
all occurrences of variables bound to script arguments are wrapped
in Force, even those that would not lead to untagging the
corresponding tagged value in the third approach above. For example,
suppose a variable bound to a script argument is passed as a parameter
to another function. With the simple Force-placement strategy
described above, the script argument would be forced at that call,
making the corresponding script appear to be used, even though the
function it is passed to might not actually use it in all cases. Hence
the set of scripts used would be overestimated.
One might use a more sophisticated strategy to insert Force
operations. For example, in the case described above one might pass
the script argument unforced to the function, and modify the
function to force it when it is used. This would require the balancer
to perform a flow analysis, to identify the functions that might be
passed a delayed script argument. Moreover, such functions might be
called sometimes with a delayed script argument, and sometimes
not. The code could be replicated to create two versions of such
functions. But with n script arguments, this might require up to
2^n versions of each function, leading to an exponential increase in
code size. An attacker could exploit this to craft a transaction that
would cause the balancer to run out of memory. This is really not
attractive.
Finally, one might finesse these problems by modifying the CEK machine
to force delayed closures automatically where the value is required,
thus enabling explicit Force operations to be omitted. This would
effectively turn UPLC into a call-by-name programming language. That would
enable this problem to be solved more easily, but at the cost of reversing a rather fundamental design decision in UPLC--and probably making the CEK machine a little bit slower, for all programs.
Thus it appears that there is no good way of using UPLC's existing lazy evaluation to observe use of script arguments.
This section discusses the 'value scripts' variation.
The main specification in this CIP represents a Script that imports
modules as compiled code applied to a list of ScriptHashes. To
prepare such a script for running, resolveScriptDependencies
replaces each hash by the term it refers to, and builds nested
applications of the compiled code to the arguments. These applications
must be evaluated by the CEK machine before the script proper begins
to run. Moreover, each imported module is itself translated into such
a nested application, which must be evaluated before the module is
passed to the client script. In a large module hierarchy this might
cause a considerable overhead before the script proper began to
run. Worst of all, if a module is used several times in a module
dependency tree, then it must be evaluated each time it is
used. Resolving module dependencies traverses the entire dependency
tree, which may be exponentially larger than the dependency dag.
The value script variation addresses this problem head on. Scripts are converted directly into CEK-machine values that can be invoked at low cost. Each script is converted into a value only once, no matter how many times it is referred to, saving time and memory when modules appear several times in a module hierarchy.
On the other hand it does restrict the syntactic form of scripts. Scripts are restricted to be syntactic lambda expressions, binding their script arguments at the top-level. This is not so onerous. But inside those λs, there must also be a syntactic value. For example, consider a module represented by a tuple, whose components represent the exports of the module. Then all of those exports need to be syntactic values--an exported value could not be computed at run-time, for example using an API exported by another module. While many module exports are functions, and so naturally written as λ-expressions (which are values), this restriction will be onerous at times.
This method does require opening up the API of the CEK machine, so that CEK values can be constructed in other modules, and introducing a way to run the machine starting from a given configuration. So it requires more invasive changes to the code than the main specification.
An alternative design would allow UPLC terms to contain ScriptHashes
directly, rather than as λ-abstracted variables, to be looked up in a
global environment at run-time. This would address this same problem:
the cost of performing many applications before script evaluation
proper begins. It would also require changes to the CEK machine, and
is not really likely to perform better than the 'value scripts'
variation (in practice, the main difference is the use of a global
environment to look up script hashes, as opposed to many per-module
ones). However, this approach is less flexible because it does not
support dynamic linking (see Static vs Dynamic Linking above). Once a
ScriptHash is embedded in a term, then a different version of the
script cannot readily be used instead. Moreover, script hashes are
quite large (32 bytes), and including more than a few in a script
would quickly run into size limits.
This section discusses the module-level recursion subvariation of
the value scripts variation.
UPLC provides a fixpoint combinator, and this is how recursion is
compiled. For the sake of argument, consider the well-known fixpoint
combinator Y (in reality, Y is not suitable for use in a strict
programming language, so the UPLC version is slightly different). We
can imagine that a recursive function f is compiled as Y h, for
some suitable h.
The difficulty that arises is that Y h is not a value, and thus
cannot appear at the top-level of a module, under the 'value script'
restriction. It can be normalised into a value, of course, using
Y h ---> h (Y h)
and then reducing the application of h; this would need to be done
by a compiler generating UPLC with the value script
restriction. But reducing h (Y h) may well duplicate Y h. When
this happens at CEK runtime it is not a problem, because all the
occurrences of Y h are represented by the same pointer. But when the
reductions are applied by a compiler, and the resulting term is
serialized to UPLC code for inclusion in a script, then each
occurrence of Y h will be serialized separately, losing sharing and
causing code duplication in the resulting script. The result could be
larger code, the opposite of what we are trying to achieve. Thus
this method of compiling recursion fits badly with the 'value scripts'
variation.
Hence module-level recursion, which allows recursive occurrences of
script values to be referred to via the Self variable instead of
using a fixpoint combinator implemented in UPLC. To take advantage of
this feature, the compiler will need to float occurrences of fix
upwards, to the top-level of a module. This can be done using suitable
analogues of the rules
(..,fix (λx.e),...) ---> fix (λx.(..,e[proj i x/x],..))
where i is the index in the tuple at which fix (λx.e) appears,
proj i x selects the ith component from x, and x does not
occur free elsewhere in the tuple; a corresponding rule for
constructor applications; and
fix (λx. fix (λy.e)) ---> fix (λx. e[x/y])
Both these rules require adjusting deBruijn numbers in the UPLC implementation.
The intention here is to implement module-level recursion using a
cyclic data-structure--the value restriction guarantees that the
module value Self is not needed to compute the top-level value of
the module, and thus there is no risk of falling into an infinite loop
at this point. (Of course, a recursive function can loop when it is
called, but constructing the function itself cannot loop because it
must be a syntactic λ-expression). This is a more efficient way to
implement recursion than the fixpoint combinators currently used in
UPLC, and so will probably become the preferred way to implement
recursion.
Note that if we adopt value scripts, but not module-level recursion,
then modules will be unable to export recursive functions without
'hiding' them in a value, such as a Delay.
This variation lifts some of the restrictions of the 'value scripts' approach, at the cost of running the CEK machine to evaluate each module, and taking care to compute and check costs correctly for the new CEK machine runs. This requires topological sorting of the scripts in a transaction before evaluation, to guarantee that we do not encounter a situation where script A depends on script B, but the budget for computing script B depends on the cost of script A--such a situation would lead to a blackhole error during script verification.
Because script bodies may now be arbitrary terms, 'module-level recursion' is no longer essential--it is possible to use fixpoint combinators in script bodies as at present. It would still improve efficiency, of course.
Note that if modules do meet the syntactic restrictions of 'value
scripts', then this variation will be less efficient than 'value
scripts'--sometimes considerably so. This is because even evaluating,
say, a large tuple whose components are λ-expressions, leads the CEK
machine to descend into, evaluate, and return out of, each component,
thus performing several CEK transitions per element. The cekValue
function must also visit each component, of course, doing the same
work, but because this is done directly in Haskell then it will be
considerably more efficient.
This variation is compatible with the various tuple-based variations, but when the script body is constrained to return a tuple then this must be checked dynamically when CEK-evaluation is complete; the check cannot be built into deserialization any more because it is no longer syntactic.
This variation changes the way modules are referenced in scripts: in the main specification, each imported module is bound to a name in the environment, and referenced using the associated variable; in this variation all imported modules are bound to a single name, and modules are referenced by projecting the corresponding component from the tuple bound to this name.
Thus: in the main specification, a module reference costs one name lookup; in this variation, a module reference costs a name lookup plus projection of a component from a tuple. However, because projecting a component from a tuple is constant time, while the cost of a name lookup is logarithmic in the number of names in the environment, then this variation may reduce the cost of module references--since scripts which import many modules will run with significantly fewer names in the environment.
Note that the uncurried script form can be generated from the curried one, by
- introducing a
λMods.outermost, - removing the
λs binding names to script arguments, - substituting
proj i Modsfor theith script argument name in the script body
Thus there is no need for any change to earlier parts of the compiler, or to the languages Plutus, PIR, or TPLC. Tuples of modules can be introduced as a last step in the generation of UPLC.
The advantage of using a global module environment instead of one tuple of modules per script is that only one, big, tuple of modules per transaction need be constructed, instead of one per script. The cost is an additional traversal of the script code, needed to adjust module indices to refer to the correct index in the global tuple of modules. By itself, this is unlikely to improve performance.
However, using a global module environment is a prerequisite to
building the module environment into the CEK machine. Doing the latter
transforms a module reference from a projection from the
tuple-of-modules variable, to a custom construction ModuleRef i that
directly accesses the module in the ith component of the global
module environment. This reduces the cost from a variable lookup plus
a projection, to just a projection; this can be expected to speed up every
reference to an external module.
This subvariation makes every reference to a module export cheaper, by replacing two projections from a tuple by one. It does require preprocessing script code before it is run, updating export references to refer to the correct element of the large tuple combining several modules. This requires a traversal of all the script code in a transaction, which must be performed every time script verification is run, including on the chain. Because of this, it makes most sense to use this subvariation in combination with 'global module environment', which also requires such a traversal. In both cases, the purpose is to adjust references to refer to the correct index in the new, merged data structure; a single traversal suffices to achieve both ends.
The syntactic restriction, requiring a module body to be a tuple of exports, is not onerous. While some compilers might wish to represent a module as built-in data, or as a function from a tag (as Solidity does), this can be achieved by placing the intended module value as the first component of a one-tuple. The implementation described here optimises away the selection of an element from such a tuple, so the restriction introduces no extra overhead in this case.
Imported modules are provided using reference scripts, an existing mechanism (see CIP-33), or in the transaction itself. Provided the cost of loading reference scripts is correctly accounted for, this CIP introduces no new problems.
Note that there is now (since August 2024) a hard limit on the total size of reference scripts used in a transaction, and the transaction fee is exponential in the total script size (see here).The exponential fees provide a strong motivation to prefer the 'lazy loading' variation in this CIP: even a small reduction in the number of reference scripts that need to be provided may lead to a large reduction in transaction fees.
The motivation for these fees is to deter DDoS attacks based on supplying very large Plutus scripts that are costly to deserialize, but run fast and so incur low execution unit fees. While these fees are likely to be reasonable for moderate use of the module system, in the longer term they could become prohibitive for more complex applications. It may be necessary to revisit this design decision in the future. To be successful, the DDoS defence just needs fees to become sufficiently expensive per byte as the total size of reference scripts grows; they do not need to grow without bound. So there is scope for rethinking here.
Some of the variations in this CIP require a traversal of all the script code in a transaction to adjust module references before execution. This should be reflected by a component in the transaction fee linear in the total size of scripts.
Since scripts invoked by a transaction specify all their dependencies as hashes, then the running code is completely known, and testing or formal verification is no harder than usual. Standalone verification of modules using 'dynamic linking' poses a problem, however, in that the code of the dependencies is unknown. This makes testing difficult--one would have to test with mock implementations of the dependencies--and formal verification would require formulating assumptions about the dependencies that the module can rely on, and later checking that the actual implementations used fulfill those assumptions.
Splitting a script into separately compiled parts risks losing optimisation opportunities that whole-program compilation gives. Note that script arguments are known in advance, and so potentially some cross-module optimisation may be possible, but imported modules are shared subterms between many scripts, and they cannot be modified when the client script is compiled. Moreover, unrestrained inlining across module boundaries could result in larger script sizes, and defeat the purpose of breaking the code into modules in the first place.
On the other hand, since the size limit on scripts will be less of a problem, then compilers may be able to optimize more aggressively. For example, today the Plinth inliner is very careful not to increase script size, but once modules are available it may be able to inline more often, which can enable further optimizations.
Moreover, today we see examples of deliberate choice of worse algorithms, because their code is smaller, and easier to fit within the script size limit. Removing the need to make such choices can potentially improve performance considerably.
As an example of how the module system might be used in a high-level language, consider the following code, which defines and uses a module implementing set insertion and membership testing, using an ordered binary tree.
data Tree a = Leaf | Branch (Tree a) a (Tree a)
empTree = Leaf
insTree a Leaf = Branch Leaf a Leaf
insTree a (Branch l b r)
| a < b = Branch (insTree a l) b r
| a > b = Branch l b (insTree a r)
| a== b = Branch l b r
memTree a Leaf = False
memTree a (Branch l b r)
| a < b = memTree a l
| a > b = memTree a r
| a== b = True
data Set = Set {emptySet :: forall a. Tree a,
insertSet :: forall a. Ord a => a -> Tree a -> Tree a,
memberSet :: forall a. Ord a => a -> Tree a -> Bool}
setMod = Set empTree insTree memTree
setModule :: Module Set
setModule = makeModule ($$(PlutusTx.compile [| setMod |]))
client set redeemer _ = memberSet set redeemer s
where s = insertSet set 1 (insertSet set 2 (emptySet set))
clientModule = makeModule ($$(PlutusTx.compile [| client |]))
`applyModule` setModule
Here the module signature is represented by a Haskell record type;
Haskell records are compiled into tuples in UPLC, and the record
fields are all values (once fixpoints are floated upwards to the
module level), so the setModule in this example fits the 'unboxed
modules' syntactic restrictions. The client script takes the record as
an argument, and uses the module exports via record field selectors,
which compile to projections from the tuple. Thus the client also
meets the syntactic restrictions for 'unboxed modules'. To make use
of these modules, the off-chain code must construct a UTxO
containing setModule as a reference script, and include it as a
reference UTxO in transactions that use the client.
Philip DiSarro describes "Merkelized validators", which offer a way of offloading individual function calls to stake validators: the client script just checks that the appropriate stake validator is invoked with a particular function-argument and result pair, checks that the argument equals the argument it wants to call the function with, and then uses the result as the result of the function. The stake validator inspects the argument-result pair, computes the function for the given argument, and checks that the result equals the result in the pair. This design pattern enables the logic of a script to be split between the client script and the stake validator, thus circumventing the limits on script size. But the main point is that the function call, whose result may be needed by several validators, can be computed just once per transaction. More details can be found here.
Factoring out a shared part of the validation in this way is a generally useful technique which is largely independent of the existence of modules--this CIP does not remove the need for sharing work between validators, and indeed this trick will work equally well once modules are added. But as a way of implementing modules, it is rather intricate and unsatisfactory.
The Web Assembly Component Model defines a high-level IDL to enable components written in different programming languages (such as C/C++, C#, Go, Python and Rust), to work together in one WebAssembly system. WASM already has a module system, and a WASM component may consist of a number of modules (written in the same language). The focus here is on interaction between different programming languages in a well-typed way. Defining such an IDL for Cardano might be useful in the future, but it is too early to do so now.
Allowing script code to be spread across many transactions lifts a commonly complained-about restriction faced by Cardano script developers. It permits more complex applications, and a much heavier use of libraries to raise the level of abstraction for script developers. Modules are already available on the Ethereum blockchain, and quite heavily used. Adopting this CIP, in one of its variations, will make Cardano considerably more competitive against other smart contract platforms.
The main alternative in this CIP is the simplest design, is easiest to implement, but suffers from several inefficiencies.
The lazy loading variation allows redundant scripts to be omitted from transactions, potentially making transactions exponentially cheaper. To take full advantage of it requires a balancer that can drop redundant scripts from transactions. Three alternative methods are described: search, the simplest, which must run script verification a quadratic number of times in the number of scripts in the worst case; garbage collection, a self-contained change to the balancer which analyses script dependencies and thus needs to run script verification only a linear number of times; a modified CEK machine which adds tagged values to the machine, which the balancer can use to identify redundant scripts in one run of script verification, possibly requiring one more run to make accurate exunit cost estimates.
The value scripts variation restricts scripts to be explicit λ-expressions binding the script arguments, with an innermost script body which is a syntactic value. Such scripts can be converted to CEK values in a single traversal; each script can be converted to a value once per transaction, rather than at every use. Module-level recursion enables recursive definitions to recurse via the module itself, rather than locally, and makes the syntactic-value restriction easier to satisfy. This variation is expected to reduce the start-up costs of running each script considerably; on the down-side the syntactic restriction would be a little annoying, and it requires CEK operations which are not currently part of the API, so it requires modifications to a critical component of the Plutus implementation.
The explicit λs variation is a half-way house between the main variation and the 'value scripts' variation. It places less onerous syntactic restrictions on script bodies, and as such can be used with the existing implementation of recursion (although efficiency would still benefit from module-level recursion). Cost accounting during script evaluation is a little intricate. It requires modifications to the loop at the core of the CEK machine.
The tuples of modules variation replaces parameters referring to individual modules with a single parameter bound to a tuple of modules, effectively uncurrying scripts wrt their module parameters. At the cost of a traversal of all the script code in a transaction to 'relocate' module references, it is possible to replace many tuples-of-modules, one per script, by a global tuple of modules for the entire transaction; a further improvement would then be to unbox modules, replacing the global tuple of modules with a global tuple of module exports. These variations reduce the cost of referring to a module export, at the cost of an additional traversal of the script code before execution. Extensive benchmarking would be needed to decide whether or not they improve performance overall.
Performance can probably be improved further by building the module environment in to the CEK machine.
The simplest alternative to implement would be the main alternative without variations. A more efficient implementation would combine value scripts with lazy loading, using tagged values in the CEK machine to analyse dynamic script dependencies in the balancer, and so drop redundant scripts from each transaction. Further improvements to performance may be achievable using a global module environment, and unboxed modules; because there are performance costs as well as benefits to these approaches, extensive benchmarking would be required to make an informed choice.
These latter variations all require modifications to the CEK machine and to the balancer, as well as resolving dependencies in scripts; that is, they are considerably more expensive to implement.
There are many variations proposed in this CIP; I do have an opinion as to which choices might be best. These are opinions, so might be proven wrong later by benchmarks.
Firstly, I believe lazy loading will be very valuable, especially for small, cheap transactions.
I don't think lazy loading will make any transaction worse, it's simply a win. In general, though, many choices have effects that will differ for different transactions, speeding some up while slowing others down. Many variations do some work before running scripts, in order to speed them up when they are actually run. These variations will tend to make small, cheap transactions more expensive, because there will be too little execution time to recoup the initial investment, while they make long-running transactions cheaper. It's necessary to make a choice here, and decide what kind of transactions to prioritize. Personally, I favour keeping small, cheap transactions cheap, even at the cost of making longer running transactions a bit slower. So I favour choosing a variation that does work in advance only if the break-even point is reached very quickly. This is a personal opinion, and could be questioned: the important thing is think about this issue and make the choices deliberately.
With this in mind, I am against variations that require a traversal of all the script code during phase 2 verification. This would be, in particular, the 'global module environment' and the 'module environment built into the CEK machine' variations. Note that syntactic restrictions which can be implemented during deserialization do not require an extra traversal of the code in this sense. Also, the 'unboxed modules' variation--in its local form--requires a traversal of the script code which treats each script independently, and so can be done at compile-time. This is not either a run-time cost that concerns me.
I like the 'value scripts' variation. I believe it may reduce costs
significantly for small, cheap transactions. It does require 'module
level recursion' as well, if recursion is to be compiled simply and
efficiently. It does constrain compilers a little bit; any compiler
that takes advantage of modules must generate code meeting the
restriction. But since modules are a new feature, no existing code
will be broken by this. It's also straightforward to meet the
restriction trivially, for example by wrapping the entire module body
in Delay. So I do not expect any major problems here.
The 'tuples of modules' variation, given efficient projections, should
simply be a performance improvement, so I am in favour of it. It does
require resolveScriptDependencies to construct the tuple before
script execution, but this replaces adding the modules one-by-one to
the environment, and should be cheaper. Moreover, accessing modules
should in most cases be slightly cheaper. Checking the restriction
that the variable bound to the tuple only appears as an argument to a
projection might be costly, requiring an additional traversal of the
code, but on the other hand that restriction exists to make adjustment
of the index possible, and this is only required by the 'global module
environment' variation. So, provided this is not adopted, one might
relax that restriction and allow scripts to refer to the tuple of
modules as a whole. That is an odd thing to do, but not actively
harmful.
The 'unboxed modules' variation is quite attractive, in its local
variant (where each script is passed a tuple of the exports from
modules that that script imports). In this variant a traversal of
the code to adjust references to module exports is needed, but can
be done at compile-time, so does not impose a cost during phase 2
verification. However, (in combination with 'lazy loading') this does
require ScriptArgs to be larger, making all scripts slightly larger
on the chain. Also, there is a start-up cost for the unboxing itself:
resolveScriptDependencies must copy all the exports from each
imported module into the same tuple (whether or not they will be
used). Modules may have quite a lot of exports--Data.Map for example
has 97--and many may not be used in any particular script. The benefit
of unboxing modules is slightly faster access to module exports when
the script runs, but for small, cheap runs we may never recover the
cost of building the unboxed tuple in the first place. On balance, I
would probably prefer not to do this, but this is not a strong
preference.
Finally, all the variations using tuples rely on efficient, constant time projections of tuple components. These are not presently available in UPLC--but they would benefit all UPLC users, not least by providing an efficient implementation for Haskell record field selectors in Haskell. Adding efficient projections for SoP data deserves a CIP of its own; it is a prerequisite for many variations here, but logically is not a part of implementing modules. A separate CIP should be written for this in the near future--it should be straightforward and uncontroversial compared to adding modules.
- determine which approach outlined in this CIP will be selected
-
plutuschanges -
cardano-ledgerchanges -
cardano-apichanges - benchmarking and testing
- integrate the feature into
cardano-node - end-to-end testing
- release at the hard fork introducing the Dijkstra era
This CIP will be implemented by the Plutus Core team with assistance from the Ledger team and the Cardano API team. Should we decide to implement tagged modules - the safer and more recommended option - then modules will be usable in existing Plutus ledger language versions (V1, V2 and V3). If we instead opt for untagged modules, modules will only be usable from Plutus V4 onwards.
Enabling modules on-chain requires a new ledger era. Therefore modules will be enabled in the Dijkstra era at the earliest.
Developers of compilers for other languages targeting Untyped Plutus Core will need to update their languages and compilers accordingly if they wish to support modules.
Alternative Cardano node implementors must update their Plutus evaluator (unless a variant is chosen that doesn't require modifying the Plutus evaluator, which is unlikely), ledger, and transaction balancer to support this feature and align with the Haskell node.
This CIP draws heavily on a design by Michael Peyton Jones, and has benefitted greatly from discussion with Ziyang Liu, Roman Kireev, and Phil Wadler.
This CIP is licensed under CC-BY-4.0.
Footnotes
-
At present, a newer ledger language version may have access to more builtin functions and more Plutus Core versions than an older ledger language version, but this difference is going away. ↩