The Problem
With the way CIP-33 is currently implemented, and all cardano spending scripts for that matter, the script must be executed for every utxo that comes from the corresponding script address even if validation is based on the entire transaction (as opposed to individual inputs). Here is an example transaction that illustrates this:
cardano-cli transaction build \
--tx-in c1d01ea50fd233f9fbaef3a295ba607a72c736e58c9c9df588abf4e5009ad4fe#0 \
--tx-in 622034715b64318e9e2176b7ad9bb22c3432f360293e9258729ce23c1999b9d8#2 \
--spending-tx-in-reference 622034715b64318e9e2176b7ad9bb22c3432f360293e9258729ce23c1999b9d8#0 \
--spending-plutus-script-v2 \
--spending-reference-tx-in-inline-datum-present \
--spending-reference-tx-in-redeemer-file $swapRedeemerFile \
--tx-in 766555130db8ff7b50fc548cbff3caa0d0557ce5af804da3b993cd090f1a8c3a#1 \
--spending-tx-in-reference 622034715b64318e9e2176b7ad9bb22c3432f360293e9258729ce23c1999b9d8#0 \
--spending-plutus-script-v2 \
--spending-reference-tx-in-inline-datum-present \
--spending-reference-tx-in-redeemer-file $swapRedeemerFile \
--tx-out "$(cat ../assets/wallets/01.addr) 2000000 lovelace + 300 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.54657374546f6b656e0a + 0 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.4f74686572546f6b656e0a" \
--tx-out "$(cat ${swapScriptAddrFile}) + 4000000 lovelace + 0 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.54657374546f6b656e0a + 250 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.4f74686572546f6b656e0a" \
--tx-out-inline-datum-file $swapDatumFile \
--tx-in-collateral bc54229f0755611ba14a2679774a7c7d394b0a476e59c609035e06244e1572bb#0 \
--change-address $(cat ../assets/wallets/01.addr) \
--protocol-params-file "${tmpDir}protocol.json" \
--testnet-magic 1 \
--out-file "${tmpDir}tx.body"
The second and third utxo inputs are from the same script address. The script I am using above actually passes or fails based on the entire transaction, not a single utxo. This design does not take full advantage of the eUTxO model where a script can see the entire transaction during validation.
The issue with the current implementation is that it is causing redundant computation checks which result in a quadratic scaling of fees. Being that my script checks all the inputs and outputs to the transaction and decides based on that, below is the fee estimation with the current CIP-33 implementation:
tx fee = # ref scripts executed * ( 0.3 ADA + 0.02 ADA * ( # input utxos + # output utxos ) )
The transaction fee increases linearly for every utxo (inputs + outputs) in the transaction, then quadratically if more than one reference script needs to be executed. The reason for this is that the script traverses all the inputs and outputs of the transaction to determine successful validation; the validation is not based on an individual utxo. So since my above transaction is spending two utxos from the script address, the reference script must be executed twice even though the second execution is completely redundant.
The Proposed Change
It would be better in this context if scripts could (but don't have to) work more like public key signatures: only one signature is required no matter how many utxos come from that user. The cardano-node is already capable of detecting if there is a utxo without the necessary accompanying script.
I admit I am ignorant about certain lower-level implementations so all I can do is ask: why can there not be a transaction level script where the datum and redeemer are attached as usual? Here is how I am proposing my above transaction would look instead:
cardano-cli transaction build \
--tx-in c1d01ea50fd233f9fbaef3a295ba607a72c736e58c9c9df588abf4e5009ad4fe#0 \
--tx-in 622034715b64318e9e2176b7ad9bb22c3432f360293e9258729ce23c1999b9d8#2 \
--spending-plutus-script-v2 \
--spending-reference-tx-in-inline-datum-present \
--spending-reference-tx-in-redeemer-file $swapRedeemerFile \
--tx-in 766555130db8ff7b50fc548cbff3caa0d0557ce5af804da3b993cd090f1a8c3a#1 \
--spending-plutus-script-v2 \
--spending-reference-tx-in-inline-datum-present \
--spending-reference-tx-in-redeemer-file $swapRedeemerFile \
--tx-out "$(cat ../assets/wallets/01.addr) 2000000 lovelace + 300 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.54657374546f6b656e0a + 0 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.4f74686572546f6b656e0a" \
--tx-out "$(cat ${swapScriptAddrFile}) + 4000000 lovelace + 0 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.54657374546f6b656e0a + 250 c0f8644a01a6bf5db02f4afe30d604975e63dd274f1098a1738e561d.4f74686572546f6b656e0a" \
--tx-out-inline-datum-file $swapDatumFile \
--tx-in-collateral bc54229f0755611ba14a2679774a7c7d394b0a476e59c609035e06244e1572bb#0 \
--change-address $(cat ../assets/wallets/01.addr) \
--tx-level-spending-tx-in-reference 622034715b64318e9e2176b7ad9bb22c3432f360293e9258729ce23c1999b9d8#0 \
--protocol-params-file "${tmpDir}protocol.json" \
--testnet-magic 1 \
--out-file "${tmpDir}tx.body"
I removed the spending-tx-in-reference
lines and added a tx-level-spending-tx-in-reference
after change-address
. If validation needs to be done on a per utxo basis, the original spending-tx-in-reference
lines can still be used.
Minting scripts can already be used in a similar manner:
cardano-cli transaction build \
--tx-in fadae52f0323d7178c8116aa6adce31aba3ad6c561cbe5b31009251f742aa1bb#1 \
--tx-out "$(cat ../../assets/wallets/01.addr) 2000000 lovelace + 1000 ${alwaysSucceedSymbol}.${tokenName}" \
--tx-out "$(cat ../../assets/wallets/02.addr) 2000000 lovelace + 50 ${alwaysSucceedSymbol}.${tokenName}" \
--mint "1050 ${alwaysSucceedSymbol}.${tokenName}" \
--mint-script-file alwaysSucceedsMintingPolicy.plutus \
--mint-redeemer-file unit.json \
--tx-in-collateral af3b8901a464f53cb69e6e240a506947154b1fedbe89ab7ff9263ed2263f5cf5#0 \
--change-address $(cat ../../assets/wallets/01.addr) \
--protocol-params-file "${tmpDir}protocol.json" \
--testnet-magic 1 \
--out-file "${tmpDir}tx.body"
Here the minting script is only executed once despite the minting occurring in two different outputs.
How would this work?
Two cases need to be considered:
- A transaction level script is used where a utxo level script should be used.
- A utxo level script is used where a transaction level script should be used.
The current way a spending script is written (in Haskell) is like this:
-- | validator function before being compiled to plutus
mkValidator :: Datum -> Redeemer -> ScriptContext -> Bool
mkValidator d r ctx = ...
Meanwhile minting scripts are written like this:
-- | minting policy before being compiled to plutus
mkMintPolicy :: Redeemer -> ScriptContext -> Bool
mkMintPolicy r ctx = ...
So taking inspiration from the fact that minting policies don't deal with a datum, I propose the following change to how spending scripts are written:
-- | new validator function before being compiled to plutus
mkValidator :: Maybe Datum -> Redeemer -> ScriptContext -> Bool
mkValidator Nothing r ctx = {- the case where the script can be used at a transaction level -}
mkValidator (Just d) r ctx = {- the case where the script can be used at the utxo level -}
When the script is used with the tx-level-reference-script
option, Nothing
is passed in for the datum. On the other hand, when spending-tx-in-reference
is used like usual, the datum will be parsed and passed with the Just
. This way the code explicitly handles both of the above cases.
Using this technique, it would also be theoretically possible to spend a utxo at a script address even if it doesn't have a datum attached. This assumes the proper accompanying logic.
What if a malicious entity forcibly passes in the wrong version of the datum (Nothing
when it should be Just d
or vice versa)?
The script logic can be written to account for this so I argue it is up to the developer to defend against this kind of attack. A simple error message when the wrong version is used could suffice for most use cases. For my use case, the code would be:
mkValidator :: Maybe Datum -> Redeemer -> ScriptContext -> Bool
mkValidator (Just _) _ _ = traceError "Not meant to be used at the utxo level" -- ^ I don't want the script used in this case
mkValidator Nothing r ctx = {- do what I want -}
Can multiple transaction level scripts be used in one transaction?
I don't see why not. The node is capable of detecting if all relevant scripts are present in the transaction. The transaction would only be valid if all necessary scripts succeed.
Does this require a hardfork?
I do not know enough to say.
Extra Comment
I wasn't sure if this should be its own CIP or if it was related enough to CIP-33 to be opened as an Issue. Being that it is extending the behavior of CIP-33, I chose the latter.