Build a Proof of Existence dApp (ink!)
Objective
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!
ink! Smart Contract
Prerequisites
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
.
Development
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 codecontract.json
: the contract metadatacontract.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 usecontract.contract
to instantiate the contract on-chain.Open
Cargo.toml
and add dependencies as followsOpen
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 theusers
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 storagefiles
, 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
andusers
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 functionsfn owned_files()
andfn has_claimed()
.fn owned_files()
andfn 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 aResult
enum type in Rust to indicate whether the function successfully changes the state by returningOk(())
, or an error occurs and the state change is reverted by returningErr(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 whatfn claim()
does above.we first check the caller owns the file. If not, we return an error.
we update the
users
andfiles
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.
Front End
Prerequisites
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.
Before We Start
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 tosrc/AccountSelector.js
.The
<NodeInfo />
component, referring tosrc/NodeInfo.js
.The
<Metadata />
component, referring tosrc/Metadata.js
.The
<Balances />
component, referring tosrc/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.
Development
Add the use-inkathon dependency by:
In
src/App.js
, let's replace the<TemplateModule />
component with<PoEWithInk />
. Remove theTemplateModule
import line and addPoEWithInk
. We also create a basic React skeleton ofsrc/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 tocess-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:
Tutorial Completion
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.
References
Last updated