An Introduction to the Concepts Behind YTxP Architecture
Introduction
The Yielding Transaction Pattern (abbr. YTxP) architecture is a principled approach to modelling and describing Cardano protocols. The concepts behind it have been used to great success in numerous protocols in production, and this article aims to explore these core concepts and demonstrate how one may effectively leverage it in the design of new protocols.
To this end, we'll incrementally develop the key ideas of YTxP by discussing how one can upgrade a token after its been deployed, and we'll see how answering this question naturally leads to the YTxP architecture. In the spirit of building an understanding, we'll implement these ideas "from scratch" but indeed many of the tedious parts have been automated away in the YTxP library. If you want to skip straight to the library, you’ll find that available here.
The Plutus Scripts of a YTxP Architected Protocol
Suppose we are creating a new token with a minting policy, say dummyMintingPolicy
, defined as follows.
import PlutusLedgerApi.V2 (ScriptContext)
import PlutusTx.Prelude qualified
{-# INLINEABLE dummyMintingPolicy #-}
dummyMintingPolicy :: PlutusTx.Prelude.Integer -> ScriptContext -> PlutusTx.Prelude.Bool
dummyMintingPolicy = dummyRedeemerIsOneLogic
{-# INLINEABLE dummyRedeemerIsOneLogic #-}
dummyRedeemerIsOneLogic :: PlutusTx.Prelude.Integer -> ScriptContext -> PlutusTx.Prelude.Bool
dummyRedeemerIsOneLogic redeemer _scriptContext =
PlutusTx.Trace.traceIfFalse "expected redeemer to be one, but it is not one" (redeemer PlutusTx.Prelude.== 1)
Note that dummyMintingPolicy
simply checks if its redeemer is one, and while the validations that dummyMintingPolicy
performs are a bit silly, the ideas that follow will generalize to a more practical protocol.
Clearly, if this contract is deployed on the network, any changes to the smart contract dummyMintingPolicy
would necessarily make a different minting policy and hence simply be a different token. To this end, an upgrade would require an exchange of the existing tokens to the new upgraded tokens. This exchange of migrating all the existing tokens to the upgraded token is undesirable since owners of the existing token must manually create transactions for the exchange themselves which incurs transaction fees. Moreover, in the case there is a major security flaw in the existing token that e.g. allows minting of arbitrary tokens, then this issue would also be present in the new token since one can mint arbitrary tokens and exchange the arbitrary amount of old tokens for the new token. Indeed, this upgrade plan is unsatisfactory, and we'll see we can do better.
The crux of the issue is how one can change the core logic of a Plutus Script without actually changing the Plutus Script itself. This idea may seem like a contradiction, but at a high level, instead of making the Plutus Script directly verify its conditions (e.g. the dummyRedeemerIsOneLogic
function), we instead make the Plutus Script always "look in the same location" to find a different script which it delegates all of its verifications to. So, we can change the logic of the former script without changing its logic (and hence it remains the same token) by changing the latter script that resides at the distinguished location (address) the former script delegates its verifications to. We call the former script a yielding script since it yields all of its checks to the latter script, and we call the latter script an authorized script since it will need certain approval that this script is the script to yield to.
Yielding and authorized script
We first tackle the implementation of the yielding script, call this minting policy dummyYieldingMintingPolicy
, which will be the upgradeable token that replaces dummyMintingPolicy
. We first investigate where the yielding script shall look for the authorized script. Recall the definition of a transaction output as follows.
data TxOut = TxOut
{ txOutAddress :: Address
, txOutValue :: Value
, txOutDatum :: OutputDatum
, txOutReferenceScript :: Maybe ScriptHash
}
Note the txOutReferenceScript
field stores a Maybe ScriptHash
. We shall use this field to store a reference to an authorized script that the yielding script will yield to. Since anyone can pay to arbitrary addresses with any value in the txOutReferenceScript
field, we must distinguish a particular transaction output with an authorized script by using a token which will provide the necessary approval of the authorized script. We call such a token a state thread token. It's crucial that this UTxO with the state thread token is protected in a reasonable sense since the security and upgrade-ability of the authorized script (and hence the entire protocol) depends on the script stored here.
The concept of state thread tokens isn't limited to the YTxP architecture -- indeed, this idea of identifying a transaction output with a token which can be modified by spending the transaction output and paying to a new transaction output finds use in many other protocol designs.
To implement the yielding minting policy, we will assume that the reference inputs will contain a UTxO which contains the state thread token and a reference script to the authorized script. After the yielding minting policy finds the authorized script, it will verify that the authorized script has run -- more on this later. Note the use of reference scripts (see CIP-33) for the authorized script leads to smaller transactions and reduced fees.
Yielding and authorized script with state thread token
import PlutusTx.Prelude qualified as PlutusTx
import PlutusLedgerApi.V1.Value qualified as V1.Value
import PlutusLedgerApi.V3 (ScriptContext (txInfoReferenceInputs), TxInInfo (txInInfoResolved))
import PlutusLedgerApi.V2 (TxOut (txOutValue, txOutReferenceScript))
import PlutusTx.Trace qualified
type DummyYieldingMintingPolicyParams = CurrencySymbol
type DummyYieldingMintingPolicyRedeemer = (PlutusTx.Prelude.Integer, PlutusTx.Prelude.Integer)
dummyYieldingMintingPolicy :: DummyYieldingMintingPolicyParams -> DummyYieldingMintingPolicyRedeemer -> ScriptContext -> PlutusTx.Prelude.Bool
dummyYieldingMintingPolicy
stateThreadTokenCurrencySymbol
(stateThreadTokenTxInputIndex, authorizedScriptIx)
scriptContext@ScriptContext{scriptContextTxInfo = txInfo} =
let txOut =
txInInfoResolved $
txInfoReferenceInputs txInfo
PlutusTx.Prelude.!! stateThreadTokenTxInputIndex
in PlutusTx.Trace.traceIfFalse
"reference input for authorized script does not contain state thread token"
( -- > assetClassValueOf :: Value -> AssetClass -> PlutusTx.Prelude.Integer
-- gets the quantity of the given AssetClass in the Value
V1.Value.assetClassValueOf (txOutValue txOut) (AssetClass (stateThreadTokenCurrencySymbol, PlutusLedgerApi.V2.adaToken))
PlutusTx.Prelude.== 1
)
&& PlutusTx.Trace.traceIfFalse
"authorized script didn't run"
( case txOutReferenceScript txOut of
Just authorizedScriptHash ->
checkIfScriptHasRun
authorizedScriptHash
scriptContext
authorizedScriptIx
Nothing -> False
)
checkIfScriptHasRun :: ScriptHash -> ScriptContext -> PlutusTx.Prelude.Integer -> PlutusTx.Prelude.Bool
checkIfScriptHasRun = {- ... more on this later ... -}
Note we do an optimization of passing the indices of where our expected elements are in the script context as the redeemer, and simply verifying that the suggested element is correct. Contrast this to doing a linear search through certain fields in the script context to find the desired output.
We now turn our attention to the final step of implementing the function checkIfScriptHasRun
i.e., the function which checks an authorized script has validated. The two most common ways of running a script in a transaction would be to either consume a UTxO at a validator address, or to mint a minting policy. We discuss each of these approaches.
To check if a validator has run, we use the fact that all validator scripts in the same transaction must also succeed, so it is sufficient to check that there exists a transaction input with the authorized script address being consumed since that means the script must validate. With this logic, we can define
checkIfScriptHasRun
withcheckIfValidatorHasRun
as follows.
import PlutusLedgerApi.V2 (
Address (addressCredential),
Credential (ScriptCredential),
ScriptContext (ScriptContext, scriptContextTxInfo),
TxInInfo (txInInfoResolved),
ScriptHash,
TxInfo (txInfoInputs),
TxOut (txOutAddress),
)
import PlutusTx.Prelude qualified
checkIfValidatorHasRun :: ScriptHash -> ScriptContext -> PlutusTx.Prelude.Integer -> PlutusTx.Prelude.Bool
checkIfValidatorHasRun authorizedScriptHash ScriptContext{scriptContextTxInfo = txInfo} authorizedScriptIx =
let txOut =
txInInfoResolved $
txInfoInputs txInfo
PlutusTx.Prelude.!! authorizedScriptIx
in case addressCredential $ txOutAddress txOut of
ScriptCredential scriptHash | scriptHash PlutusTx.Prelude.== authorizedScriptHash -> True
_ -> False
To check if a minting policy has run, we use the fact that all minting policies in the same transaction must also succeed, thus it suffices to check if a minting policy with the currency symbol of the hash of the authorized script also succeeds. With this logic, we can define
checkIfScriptHasRun
withcheckIfMintingPolicyHasRun
as follows.
import PlutusLedgerApi.V2 (
Credential (ScriptCredential),
ScriptContext (ScriptContext, scriptContextTxInfo),
TxInInfo (txInfoMint),
ScriptHash (getScriptHash),
TxInfo (txInfoInputs),
TxOut (txOutAddress),
)
import PlutusTx.AssocMap qualified
import PlutusTx.Prelude qualified
import PlutusLedgerApi.V1.Value (
CurrencySymbol (unCurrencySymbol),
Value (getValue),
)
checkIfMintingPolicyHasRun :: ScriptHash -> ScriptContext -> PlutusTx.Prelude.Integer -> PlutusTx.Prelude.Bool
checkIfMintingPolicyHasRun authorizedScriptHash ScriptContext{scriptContextTxInfo = txInfo} authorizedScriptIx =
let (currencySymbol, _tokensAndAmounts) =
PlutusTx.AssocMap.toList (getValue (txInfoMint txInfo))
PlutusTx.Prelude.!! authorizedScriptIx
in unCurrencySymbol currencySymbol PlutusTx.Prelude.== getScriptHash authorizedScriptHash
While both of these approaches work, there's problems with them:
If the authorized script is a validator, that means we must have previously created a transaction output at this validator address. This makes minting our token
dummyYieldingMintingPolicy
a "two step process" where one transaction needs to create transaction output at the address of the authorized script validator, then the next transaction must consume this UTxO. Unfortunately, this two step process induces extra fees, and other protocol participants can steal another's validator to run their yielding script instead.If the authorized script is a minting policy, recall that we must mint (or burn) a non-zero amount of tokens (see Figure 11 of the Alonzo Ledger specification1), so recalling the preservation of value property (the amount of value produced by the transaction must be the same as the amount consumed), we know that some transaction output must contain this token. From a user experience point of view, having these garbage tokens in their wallet isn't so pleasant, so alternatively one can send these tokens to another address. Sending the garbage tokens to another address wastes lovelace in minimum UTxO fees, so one may consider to burn the extra garbage tokens later, but this creates the same undesirable two step process of validator scripts.
It's clear that both of these methods require a "side effect" of executing the authorized script. We'd like to do these without side effects i.e., we'd like to simply just execute the authorized script to validate the transaction, and verify that the authorized script has executed.
Fortunately, we can abuse the fact that one can withdraw rewards corresponding to their reward address (staking credential) from the proof of stake leader election in a transaction. Staking credentials may be a Plutus script, and in the case that there are no rewards to withdraw, one may still withdraw 0 rewards. Thus, an authorized script may be a staking validator, and for the yielding script to verify that the authorized script has run, the yielding script needs to verify that there is a withdrawal from the reward account corresponding to the authorized script. So, we can define checkIfScriptHasRun
with checkIfStakingValidatorHasRun
as follows.
import PlutusLedgerApi.V2 (
Credential (ScriptCredential),
ScriptContext (ScriptContext, scriptContextTxInfo),
TxInInfo (txInfoWdrl),
ScriptHash (getScriptHash),
TxInfo (txInfoInputs),
StakingCredential (StakingHash),
)
import PlutusTx.Prelude qualified
checkIfStakingValidatorHasRun :: ScriptHash -> ScriptContext -> PlutusTx.Prelude.Integer -> PlutusTx.Prelude.Bool
checkIfStakingValidatorHasRun
authorizedScriptHash
ScriptContext{scriptContextTxInfo = txInfo}
authorizedScriptIx =
let (stakingCredential, _rewardAmount) =
PlutusTx.AssocMap.toList (txInfoWdrl txInfo)
PlutusTx.Prelude.!! authorizedScriptIx
in case stakingCredential of
StakingHash (ScriptCredential scriptHash) | scriptHash PlutusTx.Prelude.== authorizedScriptHash -> True
_ -> False
The advantage with making the authorized script a staking validator is clear -- it's the "closest approximation" to simply running the authorized script without any side effects. It's worth noting that in future versions of Plutus, CIP-112 introduces observe validators which even more directly implements this idea without the need for withdrawing 0 rewards. For now, using staking validators as authorized scripts is the preferred approach as it has no side effects.
So, the authorized script which assumes the logic of the original dummyMintingPolicy
minting policy would be as follows.
import PlutusLedgerApi.V2 (
ScriptContext (ScriptContext, scriptContextTxInfo, scriptContextPurpose),
ScriptPurpose (Minting, Rewarding),
)
import PlutusTx.Prelude qualified
authorizedStakingValidatorDummyRedeemerIsOne :: PlutusTx.Prelude.Integer -> ScriptContext -> PlutusTx.Prelude.Bool
authorizedStakingValidatorDummyRedeemerIsOne
redeemer
scriptContext@(ScriptContext{scriptContextPurpose = Rewarding _stakingCredential}) =
dummyRedeemerIsOneLogic redeemer scriptContext
authorizedStakingValidatorDummyRedeemerIsOne
_redeemer
_scriptContext = PlutusTx.Trace.traceError "expected the Plutus script to be rewarding"
Note that we verify that the script purpose is Rewarding
-- this is to ensure that this staking validator isn't used for other undesirable purposes. For example, we would like to prevent delegation of this staking validator (which would introduce the side effect of participants withdrawing potentially non-zero rewards), but worse, without this participants can de-register the staking validator meaning that participants would have to re-register the staking validator before using it again. This justifies the check for scriptContextPurpose = Rewarding _stakingCredential
.
This completes all the pieces of our token dummyYieldingMintingPolicy
which implements the logic of an upgradeable token. We've made it clear how a Plutus Script (a yielding script) can delegate all of its verifications to a particular script (an authorized script) identified by a minting policy (state thread token). In the following section, we'll discuss the associated transactions for using these Plutus Scripts.
The Transactions of a YTxP Architected Protocol
In this section, we will discuss the transactions to use the aforementioned Plutus scripts.
We first discuss the transactions to initialize the system. So far, we've left the implementation of the state thread token abstract, and we will need to make this concrete. Indeed, the key idea of the state thread token is to identify a particular UTxO with a reference script an authorized script. If we want only one authorized script to exist onchain at any point in time, it's clear the state thread token should be an NFT (i.e., a token which mints only if a specified UTxO is spent in the same transaction). For example, the following minting policy would satisfy the desired properties.
import PlutusLedgerApi.V2 (
Address (addressCredential),
Credential (ScriptCredential),
ScriptContext (ScriptContext, scriptContextPurpose, scriptContextTxInfo),
ScriptPurpose (Minting),
StakingCredential (StakingHash),
TxInInfo (txInInfoResolved),
TxInfo (txInfoInputs, txInfoMint, txInfoReferenceInputs, txInfoWdrl),
TxOutRef,
)
import PlutusLedgerApi.V2.Contexts qualified
import PlutusLedgerApi.V1.Value qualified
import PlutusTx.Trace qualified
myStateThreadToken :: TxOutRef -> () -> ScriptContext -> PlutusTx.Prelude.Bool
myStateThreadToken params _redeemer ScriptContext{scriptContextPurpose = Minting ownCurrencySymbol, scriptContextTxInfo = txInfo} =
case PlutusLedgerApi.V2.Contexts.findTxInByTxOutRef params txInfo of
Nothing -> PlutusTx.Trace.traceError "expected the parameterized TxOutRef to be spent"
Just _txInInfo ->
PlutusTx.Trace.traceIfFalse
"expected exactly one token to be minted"
$
-- > currencySymbolValueOf :: Value -> CurrencySymbol -> PlutusTx.Prelude.Integer
-- gets the total value of the currency symbol in the value map
V1.Value.currencySymbolValueOf
(txInfoMint txInfo)
ownCurrencySymbol
PlutusTx.Prelude.== 1
myStateThreadToken *params *redeemer _scriptContext =
PlutusTx.Trace.traceError "expected the Plutus script to be minting"
Now, the transaction to initialize the system may mint and pay the state thread token NFT to a public key hash address with an authorized script as a reference script. Indeed, the choice of of paying the state thread token to a public key hash address means the owner of the public key hash address has full rights to upgrade the system by spending this UTxO and paying the state thread token to a transaction output with a different authorized script as reference script. In a more practical protocol, the choice of the address containing the state thread token could be a multisignature address meaning upgrades are only possible if multiple people agree to the update.
Initialization transaction: creating the state thread token and authorized script
Inputs:
Change inputs.
Mints:
State thread token NFT
Registers:
The staking credential associated with
authorizedStakingValidatorDummyRedeemerIsOne
Outputs:
A transaction output with the state thread token NFT, and reference script
authorizedStakingValidatorDummyRedeemerIsOne
.
Initialization transaction
Note we also needed to register the staking credential associated with our authorized script by submitting a stake address registration certificate in a transaction. This is needed as it is a ledger restriction that must be satisfied in order to withdraw rewards from a staking credential.
To mint our token, recall this is the yielding script dummyYieldingMintingPolicy
, we build the transaction as follows where we note that our token delegates all of its verifications to our authorized script.
Transaction to mint the token
Reference inputs:
A transaction output with the state thread token which contains an authorized script as reference script.
Reward withdrawals:
Withdraw from the staking credential associated with the script
authorizedStakingValidatorDummyRedeemerIsOne
.
Mints:
Some number of
dummyYieldingMintingPolicy
Outputs:
A wallet address with the minted
dummyYieldingMintingPolicy
Mint transaction
Now, we discuss the transaction to upgrade the system. Recall an upgrade amounts to identifying a different authorized script with the same state thread token. This action of identifying a different authorized script with the same state thread token depends on the implementation of the state thread token and the address the state thread token. In our example, the state thread token is an NFT, and the address the state thread token resides at is an address which only permits spending if a trusted signature is provided. So, an upgrade would simply amount to spending the UTxO with the state thread token, and paying the state thread token to a new address which contains a reference script of the new authorized script. To this end, we can build a transaction to upgrade the system as follows.
Transaction to upgrade the token
Inputs:
A transaction output with the state thread token.
Outputs:
A transaction output with the state thread token with a different reference script as the authorized script.
Upgrade transaction
In this section we've seen how one can build the transactions to initialize, use, and upgrade our simple protocol. In the following section, we will finish with a brief analysis of some of the pros and cons of the YTxP architecture.
Analysis
The core of the YTxP architecture is to make a protocol's scripts yielding scripts which yield all verifications to authorized scripts. Putting all of the protocol's logic in one spot in an authorized script to validate an entire transaction helps keep protocols simple and hence makes auditing nicer (as opposed to having logic spread out over many scripts).
Not only does the YTxP architecture make it nice for humans to understand, it also can make scripts more efficient to execute. Consider an example where we have 5 of the same validator addresses being consumed in a single transaction.
A five-validator transaction
In the traditional approach of making the validator addresses simply verify that they are allowed to be spent, note that each validator address must execute its same code 5 times. Contrast this to the YTxP approach where the validators are now yielding scripts that simply verify the authorized script runs. Checking the authorized script is inexpensive (which is run for each of the 5 yielding scripts), but note the authorized script runs once to validate the entire transaction. So, spending from validators with the traditional approach has a linear growth in execution cost in the validators spent whereas the YTxP approach has an essentially constant execution cost independent of the number of validators spent. Of course, this complexity analysis depends on the implementation of the validator which depends on the protocol being implemented.
So, the return of investment of using YTxP architecture in terms of development costs are clear. YTxP architecture leads to:
Smooth smart contract upgrades to protect your protocol against vulnerabilities after its been deployed.
Protocol implementations that are easier to understand for humans which reduces developer onboarding time, and developer maintenance time.
More efficient smart contracts which means reduced deployment fees.
Conclusion
We've demonstrated the key ideas behind the YTxP architecture with sample code, shown the required transactions to put a protocol with a very simple token in action, and finally did a brief analysis of how the YTxP architecture can lead to simpler and more efficient protocols.
For further reading, readers are encouraged to checkout the ytxp-lib library which conveniently wraps up a lot of the tedious parts of implementing YTxP style scripts, and is loaded with many more exciting features. It provides a CLI tool to create yielding scripts (which accept a special redeemer) from any custom script. Moreover, ytxp-lib should also be used in combination with ytxp-sdk which defines types and the encodings of the special redeemers used to trigger the scripts correctly to build transactions.
If you like what you're reading and this sounds relevant to your business, please reach out. The YTxP architecture presents exciting opportunities to create upgrade-able, maintainable, and efficient protocols on Cardano for your business needs.
A Formal Specification of the Cardano Ledger integrating Plutus Core Deliverable GL-D2 (Alonzo ledger specification)