Build a Proof of Existence dApp (ink!)
Last updated
Last updated
In this tutorial, we will walk through a full dApp development experience and write out both the ink! smart contract and the React front end. We will also deploy the smart contract on a CESS local node and have the front end interact with it.
The application that we will develop is a Proof of Existence (PoE) dApp. It is used to prove that 1) one is the owner of a particular file, and 2) the file has the same content as it is at the point it was published on-chain. To understand the use case of the second point, think about a user who want to prove the content of a business contract, written in the form of a smart contract, has not been tampered with from the point it was published.
Instead of posting the whole file content on-chain, we will extract the first 64kB of the content, pass it to a hash function, and claim ownership of the file. Users can also send transactions on-chain to retrieve a list of their owned files and check if a file has been claimed. Note that this hashing mechanism is not secure for production use and serves only as demonstration purpose here.
The complete source code of this tutorial can be seen at the github repository.
For the smart contract, refer to ink/poe
directory.
For front end, refer to the src/ProofOfExistenceInk.js
.
We will first code the ink! smart contract side and then go back to the front-end side. Let's jump right in!
This section has the same prerequisites as the tutorial Deploy an ink! Smart Contract. Please follow that section and install all required components: Rust and cargo-contract
.
Let's start by building the directory structure
The last command will create an ink! contract project skeleton.
Inside the directory, there are three files.
The most interesting file is the lib.rs
. It is a simple contract that reads and flips a boolean value. Please skim through it to get an idea of how ink! contract code is structured.
Let's build and test the contract project.
Running the cargo contract build
yields three files:
contract.wasm
: the contract code
contract.json
: the contract metadata
contract.contract
: the contract code and metadata
The front end (see next section) will need to read contract.json
to know the API of the contract. We will use contract.contract
to instantiate the contract on-chain.
Open Cargo.toml
and add dependencies as follows
Open lib.rs
. Let's remove everything and only keep the top-level structure. So we have:
In smart contract development, two of the keys are deciding how the storage data structure looks like and what events it emits. Let's think about the PoE functionality. We will need a storage from user addresses mapping to an array of files, indicating the hash digests of the files they owned; and a reverse map from the hash digest to its owner. Currently we don't support the same file / file digest to be owned by multiple users. The following storage structure supports this:
We use #[ink(storage)]
attribute macro to tell the Rust compiler this is the smart contract storage, and the compiler will further process the storage specification here. Check here to learn more about the macros ink! supports.
Now, we want our smart contract to emit events when a user successfully claims the file ownership. Let's also allow the user to forfeit the claim in the future. So the two events are:
We specify two events here:
Claimed: when this event is emitted, it contains the associated user and the file digest.
Forfeit: same as above, containing the associated user and the file digest.
Let's work on the core logic of the smart contract
Here, we have defined three methods:
fn new()
: the smart contract constructor. This function will be called when the smart contract is instantiated. It is marked with #[ink(constructor)]
attribute macro right above the function.
fn owned_files(&self)
: This function returns a vector, the Rust way of saying an array, of hash digests. It first retrieves the caller of the smart contract, uses it as the key to retrieve its value inside the users
storage map, and returns the value. If no value is found, an empty vector is returned.
Notice this function doesn't take an AccountId
parameter in, so callers can only check their own file ownerships.
fn has_claimed(&self, file: Hash)
: The function returns a boolean, indicating if the file specified by the hash has been claimed by another user. We read from the smart contract storage files
, using the file hash as the key, and see if an owner can be retrieved. If yes, the file is claimed, and the function returns true. Otherwise, it returns false.
Notice we still need to specify what hash function to use and how a file is converted to its hash digest. It is a job performed on the front end, so we will take care of this in the next section.
Now let's work on the core logic of fn claim()
. It allows a user claims the ownership of a particular file digest. The overall logic is that:
We first check if the file digest has been claimed.
We update the storage files
and users
to indicate the caller claims the file ownership.
Emit an event that the file has been claimed so other listeners know about this.
The following code entails the above logic:
You may have noticed the return value type of fn claim()
looks different from the two previous functions fn owned_files()
and fn has_claimed()
. fn owned_files()
and fn has_claimed()
are view functions. They only read the contract storage but don't alter it. fn claim()
, on the other hand, is a state-modifying function. It returns a Result
enum type in Rust to indicate whether the function successfully changes the state by returning Ok(())
, or an error occurs and the state change is reverted by returning Err(error value here)
.
Now, let's define the error values. Two error values will be emitted in the contract: when users try to claim a file that has already been claimed, AlreadyClaimed
, and when users try to forfeit the file ownership they don't own, NotOwner
.
The fn forfeit()
function basically reverses the operation of what fn claim()
does above.
we first check the caller owns the file. If not, we return an error.
we update the users
and files
storage to remove the file hash digest.
emit an event about this new state.
By this point, you have completed all the core logic of the smart contract. Compile the contract with cargo contract build
to ensure it builds. If there is any doubt about the final source code, you can always refer to the source code here.
After the compilation, deploy the contract on your local cess dev chain and interact with the contract to test it. You can access Contracts UI, connect it to your local node, and deploy the contract. Refer to the screenshot below.
Ensure that:
Contracts UI is connecting to the local CESS node.
You see the expected metadata when uploading the contract.contract
file.
Congratulation! You have completed the smart contract development section. Before heading to the front-end development, the complete source code also contains the unit test code, the code block inside mod tests { ... }
. We won't go over them here as they are quite self-explanatory. Please take a look. You can run them by cargo test
command. Check here to learn more about contract testing.
Install Git
Install Node v18
Install pnpm, or your favorite package manager
Run a local development chain of the CESS node as the front end will connect to the local CESS chain. Refer here on how to run a local CESS chain.
We will start from a forked version of the Parity maintained Substrate Front End Template.
If you see a screen similar to the following, you are good to go.
First of all, in case there is any doubt, you can always refer back to the entire front end source code.
To get a high-level understand of the front end template, let's refer to the second half section of src/App.js
:
Let's see how different components are laid out on the screen.
The <AccountSelector/>
component, referring to src/AccountSelector.js
.
The <NodeInfo />
component, referring to src/NodeInfo.js
.
The <Metadata />
component, referring to src/Metadata.js
.
The <Balances />
component, referring to src/Balances.js
.
We will add a new component and showcase how to use use-inkathon javascript library connecting the front end to the smart contract.
Add the use-inkathon dependency by:
In src/App.js
, let's replace the <TemplateModule />
component with <PoEWithInk />
. Remove the TemplateModule
import line and add PoEWithInk
. We also create a basic React skeleton of src/ProofOfExistenceInk.js
.
So, src/App.js
becomes:
Open the file src/ProofOfExistenceInk.js
and add the following code:-
At this point, the front end should show the line "Proof of Existence Ink! dApp".
From now on, we will mainly focus on the file src/ProofOfExistenceInk.js
. We will not be adding code line by line here, but focus on the APIs provided by use-inkathon library that facilitate ink! smart contract interaction.
Refer to the code src/ProofOfExistenceInk.js
.
Starting from the bottom, we have:
UseInkathonProvider
context hook provides ink! contract connection information to its children components. A config object is passed in with the name, and:
defaultChain
: there are public chains with well-known IDs. As we connect to a local development chain, we set it to cess-local
.
deployments
: takes an object with contract information, such as contractId, networkId, abi and contract address.
With UseInkathonProvider
, we can make ink! API calls inside <ProofOfExistenceInk />
component.
Looking at the code inside function ProofOfExistenceInk(props) {...}
Use useInkathon()
to get the current active account and rpc api.
Use useBalance(account)
to get the account's current balance.
Use useRegisteredContract(contractId)
to get the contract ABI.
At this point, we have a contract instance poeContract
that we can interact with.
6. Here, we specify how the file hash digest is computed.
7. We then implement a few helper components TxButton, ConnectWallet, and WalletSwitcher to display the UI.
8. Finally, we have a front end similar to the following:
Congratulation! Let's recap what we have done in this tutorial:
We have successfully implemented a PoE logic in ink! smart contract and deploy it on a local CESS node.
Starting with the Substrate Front End Template and use-inkathon React library, we have successfully implemented the front end that interacts with the smart contract.
Now, you can build your dApps and deploy them on the CESS testnet to test it out. For the next step, you can also learn how to develop a dApp with Solidity smart contract as well.