Introducing Olive Oil Trust: smart contracts
This post is part of a series of posts regarding Olive Oil Trust:
- Introducing Olive Oil Trust
- Introducing Olive Oil Trust: smart contracts
- Introducing Olive Oil Trust: subgraph
- Introducing Olive Oil Trust: front end
Olive Oil Trust smart contracts are implemented in order to adopt a set of rules so that all members of the supply chain that have the same roles are established rigorously within the same framework.
In this post I will elaborate on how I designed, wrote, tested and deployed these contracts.
In this article
Designing the contracts
In order for an account in Ethereum to become a member in Olive Oil Trust with a specific role, it has to deploy a contract that inherits from that role contract (the contracts are written in Solidity language).
Members have ownership of their own tokens and escrows (or certificates in the case of certifiers) in order to perform their actions and set states accordingly.
Let's say we are a bottling plant named Bottling Company and we'd like to adopt this role in Olive Oil Trust. We'd have to deploy the following contract:
As we can see this would require the deployment of two token contracts and an escrow contract first in order to pass their addresses as arguments and initialise the contract.
Furthermore, Olive Oil Trust contracts are designed for use in upgradeable contracts, as we can see in the code block above since the contract inherits from OpenZeppelin's Initializable.sol
and there is an initialize
function instead of a constructor.
Olive Oil Trust uses upgradeable contracts to give the possibility to their members to upgrade their contracts to a newer version.
When deploying an upgradeable contract with OpenZeppelin upgrade plugins, a proxy is deployed which cannot be altered.
Instead, the implementation contract to which the transactions are forwarded would be replaced for a new one if we were to upgrade the contracts.
Please, visit this blog post from OpenZeppelin for more information.
Most of the contracts follow an UUPS proxy pattern; however, due to size limits, some others follow a transparent proxy pattern instead.
The contracts that conform Olive Oil Trust are shown below:
Roles
There are seven roles in Olive Oil Trust: olive growers, olive oil mills, bottle manufacturers, bottling plants, distributors, retailers and certifiers.
You can find them all in
hardhat-env/contracts/OliveOilTrust/roles
.
They inherit from base contracts as well as OpenZeppelin's utils
and access
upgradeable contracts.
For example, let's focus on the contract for the bottling plant role and go over its main aspects and non-private functions.
As we can see this contract inherits from six contracts:
-
@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol
: this is a base contract that aids us in writing upgradeable contracts with the use of modifiers. -
@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol
: an upgradeability mechanism designed for UUPS proxy. To properly use it, the_authorizeUpgrade
function must be overridden to include access restriction to the upgrade mechanism, as we can see in line 53. -
hardhat-env/contracts/OliveOilTrust/base/DependentCreator.sol
: this contracts gathers functions regarding the mintage of dependent tokens, which we'll see later in this post. -
hardhat-env/contracts/OliveOilTrust/base/BaseSeller.sol
: base contract that implements actions taken by a seller in the value chain. It interacts with the member's owned tokens and escrow contracts. -
@openzeppelin/contracts-upgradeable/token/ERC1155/utils/ERC1155HolderUpgradeable.sol
: it allows the contract to hold ERC1155 tokens. -
@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol
: it provides an access control mechanism.
__BottlingPlantUpgradeable_init
gathers linearized calls to parent initializers whereas __BottlingPlantUpgradeable_init_unchained
is equivalent to the initializer function minus the calls to parent initializers and gathers the logic that would be in a constructor function.
Initializer functions are not linearized by the compiler, like constructors are. This mechanism, which I have replicated from OpenZeppelin's upgradeable contracts, is used to avoid initialising the same contract twice.
The contract stores the addresses of industrialUnitToken_
and escrow_
in state variables and grants permissions for:
-
escrow_
to operate with the bottling plant's tokens inindustrialUnitToken_
. The escrow must be be able to transfer the bottling plant's tokens to itself when depositing them. -
industrialUnitToken_
to operate with the bottling plant's tokens independentToken
. The industrial unit token contract must be able to transfer the bottling plant's tokens to itself when packing them.
Let's now look at the functions inherited from DependentCreator.sol
(since the rest of the functions merely call respective functions of tokens and escrows and rely on their implementation which we'll see later).
These functions, which rely entirely on their implementation in DependentCreator.sol
, are:
setTokenTypeInstructions
andsetTokenTypesInstructions
: these functions set one set, or multiple sets, of instructions so that aDependentCreator
member can validate the mintage of a new token of that type. Both functions rely entirely on its implementation inhardhat-env/contracts/OliveOilTrust/tokens/DependentTokenUpgradeable.sol
which we'll see later in tokens.
mint
andmintBatch
: they mint one batch or multiple batches of a type of token.
Although, it might confuse that
mint
mints one batch of a token andmintBatch
mints several batches of a type of token, this is to go on with the nomenclature used by OpenZeppelin in the implementation of the ERC-1155 multi token standard.
For every batch that is going to be minted, these functions validate that we have enough units of the tokens that are required to mint the tokens we need.
Validation.validate
fulfills the following purposes:
-
It performs security checks to ensure all the dimensions of the arrays passed are valid and reverts otherwise.
-
It gets the instructions of the type of token that is to be validated and ensures that the tokens being used to mint the dependent token comply with these instructions.
-
If the aforementioned steps are successful, it consumes the instructed amount of every input token used in the process.
We can see the implementation below:
If the address/es of the token/s to be consumed does/do not match the address/es set in the instructions, it is assumed that these instructions use a certificate/s for that type of token.
Then, the function
validate
consults that certificate/s if the address/es of the token/s to be consumed is/are certified.
Once the parameters of the token/s to be minted are validated, an event or multiple events of type TokenAncestrySet
is/are emitted. Then, the batch/es of token/s is/are minted.
This event stores information about the token ancestry so that it can be easily traced to its origin.
Tokens
There are three types of tokens:
-
Independent tokens: tokens that do not require other tokens to be minted.
-
Dependent tokens: tokens that are the result of transforming other tokens, thus being dependent on their availability.
-
Industrial unit tokens: tokens that wrap tokens that represent commercial units (olive oil bottle tokens).
In order to simplify the process and somehow set the origin from where to start tracing a product, olive growers and bottle manufacturers both mint independent tokens in Olive Oil Trust, i.e. original tokens.
The implementation of an independent token is simpler than that of a dependent token since there are no instructions to set or token mintage validation, so let's look at the code for dependent and industrial unit tokens.
Dependent tokens
The main functions in DependentTokenUpgradeable.sol
are:
-
setTokenTypeInstructions
andsetTokenTypesInstructions
: they check that the parameters passed are valid, that the id/s of the type/s of token/s are not duplicated and, furthermore, they set the instructions in a mapping state variable and emits an event or multiple events of typeTokenTypeInstructionsSet
so that the instructions can be indexed by the subgraph mappings.hardhat-env/contracts/OliveOilTrust/tokens/DependentTokenUpgradeable.sol -
mint
andmintBatch
: they mint one or multiple batches of tokens. Tokens in a batch can either be non-fungible or fungible depending on if there is only one or multiple, respectively.All the units of the same batch are similar and, therefore, fungible. However, they are different from those of other batches even if they share the same type of token.
hardhat-env/contracts/OliveOilTrust/tokens/DependentTokenUpgradeable.sol -
getInstructions
: this is a view function that returns the instructions of a given batch.hardhat-env/contracts/OliveOilTrust/tokens/DependentTokenUpgradeable.sol
Industrial unit tokens
One notable difference between independent and industrial unit tokens is that even though both implement ERC-1155 tokens, IndustrialUnitTokenUpgradeable.sol
is enabled to hold ERC-1155 tokens (it inherits from OpenZeppelin's ERC1155HolderUpgradeable.sol
).
In other words, the ownership of an ERC-1155 token can be transferred to the address of IndustrialUnitTokenUpgradeable.sol
.
This is necessary to comply with the packing and unpacking logic that has to be implemented.
The main functions of the contract are:
-
pack
andpackBatch
transfer ownership of the tokens to be packed from the bottling plant to the address of the industrial unit owned by the bottling plant. It emits an event of typeSinglePacked
orBatchPacked
.hardhat-env/contracts/OliveOilTrust/tokens/IndustrialUnitTokenUpgradeable.sol -
unpack
andunpackBatch
transfer ownership of the tokens to be packed from the address of the industrial unit owned by the bottling plant to the bottling plant. Then the industrial unit/s is/are burnt. It emits an event of typeSingleUnpacked
orBatchUnpacked
.hardhat-env/contracts/OliveOilTrust/tokens/IndustrialUnitTokenUpgradeable.sol -
The rest are three view functions to obtain information about the tokens.
hardhat-env/contracts/OliveOilTrust/tokens/IndustrialUnitTokenUpgradeable.sol
Escrows
Escrows are the contracts that members use to trade tokens. Therefore, they are a mechanism to transfer the ownership of a token, or tokens, in exchange of an amount of ether but without the involvement of third parties.
There are three different escrows:
-
AgriculturalEscrowUpgradeable.sol
: this escrow is meant to hold olive tokens in the agricultural phase of the olive oil value chain. There is a different escrow to hold this independent token because the price is set by the olive oil mill after checking the qualities of the olives that it is considering to buy. This is similar to making an offer to the olive grower for the ownership of their product. -
CommercialUnitsEscrowUpgradeable.sol
: this type of escrow is designed to hold the rest of the types of tokens but the industrial units. It is similar to the escrow described above but except the price is set by the seller. -
IndustrialUnitsEscrowUpgradeable.sol
: this escrow is meant to hold industrial unit tokens. The main difference with the other escrows is that it does not require an amount to be specified for every token deposited since all industrial units are minted as a single unit.
For instance, a bottling plant owns an escrow contract that inherits from IndustrialUnitsEscrowUpgradeable.sol
since it sells industrial units to distributors.
This type of escrow also inherits from OpenZeppelin's contract ERC1155HolderUpgradeable.sol
because the escrow will become owner of the tokens that are deposited until the escrow is closed:
The process of buying a token is represented in up to three of six different stages:
-
A token/s is/are deposited, which sets the state of the escrow to
Active
.- The deposit of the token/s could now be reverted before a member deposits ether which would set the state to a final
RevertedBeforePayment
and would transfer the token/s back to the seller.
- The deposit of the token/s could now be reverted before a member deposits ether which would set the state to a final
-
The required amount of ether to buy the token/s is/are deposited, which sets the state to
EtherDeposited
.-
The deposit of ether could now be cancelled by the buyer candidate, which would set the state back to
Active
and would transfer the funds back to the buyer. -
The deposit of the token/s could also be reverted after ether is deposited, which would set the state to a final
RevertedAfterPayment
, transfer funds back to the buyer and token/s back to the seller.
-
-
The escrow operation is completed and buyer and seller get token/s and ether respectively. The state of the escrow is finally set to
Closed
.
RevertedBeforePayment
, RevertedAfterPayment
and Closed
are final states, meaning once these states are reached the escrow cannot be further modified, and neither will be holding ether nor tokens any longer.
Every single stage is reached by interacting with the escrow through a set of functions.
As we'll see below, there is a check at the beginning of every function (before any transfer) that makes it revert if the state to perform the called action is not the required one.
For instance, an escrow cannot be closed if funds have not been deposited.
Every single function emits an event of its type to store the required information of the escrow so that it can be indexed by the subgraph mappings.
Let's go over these functions:
-
depositToken
anddepositBatch
: these functions deposit one or more tokens to the escrow. The functions perform security checks to prevent invalid information to be set to the state of the escrow. Lastly, the tokens are transferred to the escrow contract. This is possible because permissions were granted to that effect, as we saw earlier, inBottlingPlantUpgradeable-__BottlingPlantUpgradeable_init_unchained
. They can only be called by the owner of the contract, i.e. the seller. Their implementation is shown below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol -
revertBeforePayment
: it sets the state of the escrow toRevertedBeforePayment
and transfers the tokens back to the seller. No more actions have to be done since no funds have been deposited at this point in time. It can only be called by the owner of the contract, i.e. the seller. Check the implementation below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol -
makePayment
: it sets the state toEtherDeposited
and transfers ether to the escrow, it only accepts the same amount of ether as the price set by the seller. We can see the implementation below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol -
cancelPayment
: it cancels the payment, i.e. it sets the state of the escrow back toActive
and sends the funds back to the buyer candidate. Only the same address that deposited the funds can cancel that payment. Check the implementation below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol -
revertAfterPayment
: it sets the state toRevertedAfterPayment
, transfers the funds back to the buyer candidate and the token/s back to the seller. It can only be called by the owner of the contract, i.e. the seller. We can see the implementation below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol -
close
: it sets the state toClosed
, transfers the funds to the seller and the token/s to the buyer. It can only be called by the owner of the contract, i.e. the seller. Check the implementation below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol -
escrow
andstate
: these view functions return the fields of the structMyIndustrialUnitsEscrow
of a given escrow and the state of this escrow respectively. Check the implementation below:hardhat-env/contracts/OliveOilTrust/escrows/IndustrialUnitsEscrowUpgradeable.sol
Certificate
The certificate contract enables the certifier to attest to the validity of a type of token to a certain standard.
As we can see below, it inherits from UUPSUpgradeable
(so it overrides _authorizeUpgrade
) and it takes a base URI to initialise the contract:
CertificateUpgradeable.sol
provides the owner of the contract, a certifier, with the function certifyToken
to validate a type of token and certifyBatch
to validate multiple types of tokens.
Both emit events to store information about the certification/s:
CertificateUpgradeable.sol
also implements three view functions:
-
isCertified
: it returns if a type of token is certified.hardhat-env/contracts/OliveOilTrust/certificates/CertificateUpgradeable.sol -
certificatesOf
andcertificatesOfBatch
: the former returns the certificates of a given type of token and the latter multiple types of tokens.hardhat-env/contracts/OliveOilTrust/certificates/CertificateUpgradeable.sol -
uri
: it returns the base URI of the certificate.hardhat-env/contracts/OliveOilTrust/certificates/CertificateUpgradeable.sol
Testing the contracts
This application runs unit tests in all the roles, tokens, escrows, certificate and members contracts:
Since most of the contracts inherit from other base contracts, tests of contract functions are a call to a behaviour scenario function.
This testing strategy makes heavy use of fixtures for the local chain to reach the desired state before each test of a function.
For instance, let's focus on the test of the bottling plant contract. It exports a function called shouldBehaveLikeBottlingPlantUpgradeable
that gathers two main describes
: effects functions and view functions.
These obviously gather the functions that change the state of the chain and the view functions. So, for instance, we'll go over the test of the mint
function:
A fixture is loaded before running the test so that the bottling plant contract gets deployed, initialised and instructions for its types of tokens are set.
This means that an independent token, an industrial unit token and an industrial unit escrow will be deployed as well.
Furthermore, this fixture also waits for these contracts to mint tokens and transfer ownerships to the bottling plant contract.
This makes it all easy and fast for reaching a convenient state so that dependent tokens can be minted.
The function shouldBehaveLikeMint
gets the parameters it needs to perform tests for three types of cases, or contexts:
-
suceeds: it gathers the tests that expect the function to succeed. For instance, this is the unit test for the mintage of a dependent token:
hardhat-env/test/shared/base/DependentCreator/effects/mint.ts -
fails: it gathers the tests that expect the function to fail. We can see below the test for the case when the ids and addresses arrays passed to the
mint
function are invalid:hardhat-env/test/shared/base/DependentCreator/effects/mint.ts -
modifiers: this context gathers tests of all the modifiers that the function uses (
mint
only uses the modifier of access controlonlyOwner
):hardhat-env/test/shared/base/DependentCreator/effects/mint.ts
Deploying the contracts
The script hardhat-env/scripts/deploy.ts
can be used to deploy the contracts of the members in Olive Oil Trust.
As we can see below, it will deploy all the contracts of the members and the contracts that these contracts need deployed in advance (tokens and escrows, or certificates) to pass their addresses.
As we deploy the contracts locally, localhost
is passed as the network parameter in the following command:
The code above will write a JSON file for every contract deployed with the fields address
, contractName
, module
, startBlock
, abi
, bytecode
and deployedBytecode
under a folder with the name of the network that is passed to the script.
This information will be used in the subgraph and front-end workspaces.
For instance, the subgraph will use data from the deployments to generate the subgraph manifest as we can see in the following post of this series of posts.