Security
In this section we're going to highlight some important security considerations when building a smart contract. Remember, smart contracts on illium work a little different from smart contracts on other platforms, so it's important to take care when designing your contract and think through all the ways someone might try to attack it.
Output Commitments
In order to spend an utxo in illium one needs to know the full preimage of the output commitment. Smart contracts are typically used by multiple parties who do not necessarily trust each other. It may be possible for one of those parties to set one of the preimage values to a value unknown to the other users of the contract, thus preventing all other parties from interacting with the contract.
How do we prevent this? We need to enforce covenants on the all the preimage fields that are unknown to all the users ensuring that users are able to construct the full preimage.
The output commitment preimage fields are:
- script-hash: If this is a recursive script usually you'll enforce a covenant requiring this script-hash to be the same as the input script-hash.
- amount: You'll typically want to enforce that the contract's amount is computed correctly.
- asset-id: Don't forget to enforce the correct asset-id as this is one possible field that can be manipulated otherwise.
- salt: In normal transfers the salt is usually set to a random number generated by the sender. But if you let users of your contract put any value here, then the value will be unknown to all other users. A good practice here is to require the salt be set to the hash of the input salt.
- state: Usually you'll be enforcing that the state is computed correctly.
Example:
(lambda (locking-params unlocking-params input-index private-params public-params)
!(import std/inputs/script-hash)
!(def new-amount (;; some code to compute the new amount here))
!(def new-state (;; some code to cmpute the new state here))
;; Compute the required output
!(def required-output !(list
(script-hash !(param priv-in input-index))
new-amount
!(param priv-in input-index asset-id)
new-state
(hash !(param priv-in input-index salt))))
;; Enforce covenant requring output 0 to be of the required form
!(assert-eq required-output !(param priv-out 0))
t
)
State transitions
As we talked about in the storage section, some contracts have a very large state that needs to be stored off chain with only the root hash of the off-chain database stored in the contract.
For these contracts in order to spend the utxo one not only needs to know the output commitment preimage, but also the private unlocking-parameters needed to update the off-chain database.
These parameters are not normally found in the contract, so it's up to you to enforce a covenant requiring they be put in the transaction ― typically in the ciphertext field.
The following contract adds new data to the contract storage and requires the data be included in the ciphertext.
(lambda (locking-params unlocking-params input-index private-params public-params)
!(import std/merkle-db)
!(import std/crypto/encrypt)
!(import std/inputs/script-hash)
!(def new-amount (;; some code to compute the new amount here))
;; Adding new data to the state db
!(def data-to-store (car unlocking-params))
!(def merkle-proof (car (cdr unlocking-params))))
!(def new-state (db-put !(param priv-in input-index state) data-to-store merkle-proof))
;; Compute the required output
!(def required-output !(list
(script-hash !(param priv-in input-index))
new-amount
!(param priv-in input-index asset-id)
new-state
(hash !(param priv-in input-index salt))))
;; Enforce covenant requring output 0 to be of the required form
!(assert-eq required-output !(param priv-out 0))
;; Enforce a covenant requring that the ciphertext contains (required-output data-to-store)
;; and is encrypted with the hash of the input-salt.
!(assert-eq !(param pub-out 0 ciphertext) (encrypt !(list required-output data-to-store) (num (commit !(param priv-in input-index salt)))))
t
)
Instance IDs
When a smart contract wants to interact with another smart contract it's not sufficient to simply know the script-hash
of the contract as that script-hash
could have multiple instances deployed on chain. You likely only want your contract
to interact with one of those instances.
To identify a specific instance of a contract we're going to need to encode an instance-id
in the state. And this
instance-id
should be encoded in such a way that it is unique to the contract and cannot be shared by other instances.
A good way to do this is to use the nullifier of the deployment transaction as the instance-id
as it's guaranteed to
be unique.
For example:
(lambda (script-params unlocking-params input-index private-params public-params)
!(def state !(param priv-in input-index state))
!(def instance-id (if (car state)
(car state)
!(param nullifiers input-index)))
!(def new-state !(list instance-id))
)
Asset IDs
The transaction validation program only validates that:
- The sum of ILX outputs plus the transaction fee <= the sum of the ILX inputs
- The sum of each unique output token <= the sum of each unique input token
Thus, one might try to steal coins by attaching an input for a junk token and sending that token to the contract output.
For example:
Inputs
======
0: smart-contract, 100,000,000 (ILX)
1: token, 100,000,000 (JUNK)
Outputs
=======
0: smart-contract, 100,000,000 (JUNK)
1: attacker-address, 100,000,000 (ILX)
This transaction would pass the validation code since the input and output amounts balance. To prevent such an attack you need to enforce a covenant setting the asset-id for your contract output correctly.
Overflows
Makes sure when working with any arithmetic operations you consider the possibility of overflows. As you do so
keep in mind that both ILX and token amounts cannot exceed a u64
even though lurk supports larger integers.