Learn how to optimize a dApp by employing structs, events, and filters in Solidity and Web3.py as they relate to the ERC-721 standard. Also, learn about decentralized storage techniques.
By the end of this post, readers will be able to:
After defining terminology, we’ll build a smart contract that allows users to add appraisal values and comments to a tokenized piece of art. The focus is on using events and filters to build out the contract.
The contract needs a way to represent the artwork information, like the name of the artwork, the name of the artist, and the current appraisal value.
While it’s possible to create separate variables for each piece of information, we’ll instead group them together into a struct
, which is short for “structure.” A struct
is similar to a Python data class. Ethereum developers frequently use the struct
to organize related pieces of data.
To define a struct
, we name and define a unique data type, which consists of a structured collection of data. Inside the struct
, we define the name and the type of each variable that belongs to the struct
. In Solidity, the variables that exist inside a struct
are called members (or, sometimes, fields).
For the ArtRegistry contract, we will use the keyword struct
followed by the name Artwork. The struct will contain three pieces of data:
struct Artwork {
string name;
string artist;
uint256 appraisalValue;
}
Organizing related pieces of data makes it easier to use them with other data types, such as mappings.
With Ethereum and many other blockchains, storing data in the contract and on-chain is expensive. To counter this issue, a new mechanism in Solidity has been created: the event. An event in Solidity is an inexpensive way to record data as a log entry on the blockchain. Historical changes in values can be documented without storing them directly in the contract.
Events work in a similar way as functions. First, an event is defined. In this case, an event named Appraisal
is defined. The Appraisal
event has three parameters: token_id
(of type uint256
) appraisalValue
(of type uint256
), reportURI
(of type string
).
The event can be accessed, or fired, from any function inside the contract. When the event is fired, the arguments that get passed to the event will be recorded as a log entry on the blockchain. The Appraisal
event will be used to log new artwork appraisals to the blockchain.
Now that the code defines a struct
that stores the metadata for the Artwork
, and an event
that catalogs the new Appraisal
value, it’s time to focus on the code associated with registering a piece of artwork.
The complete code for the ArtRegistry
contract is as follows:
pragma solidity ^0.5.0;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v2.5.0/contracts/token/ERC721/ERC721Full.sol";
contract ArtRegistry is ERC721Full {
constructor() public ERC721Full("ArtToken", "ART") {}
struct Artwork {
string name;
string artist;
uint256 appraisalValue;
}
mapping(uint256 => Artwork) public artCollection;
event Appraisal(uint256 token_id, uint256 appraisalValue, string reportURI);
function registerArtwork(
address owner,
string memory name,
string memory artist,
uint256 initialAppraisalValue,
string memory tokenURI
) public returns (uint256) {
uint256 tokenId = totalSupply();
_mint(owner, tokenId);
_setTokenURI(tokenId, tokenURI);
artCollection[tokenId] = Artwork(name, artist, initialAppraisalValue);
return tokenId;
}
function newAppraisal(
uint256 tokenId,
uint256 newAppraisalValue,
string memory reportURI
) public returns (uint256) {
artCollection[tokenId].appraisalValue = newAppraisalValue;
emit Appraisal(tokenId, newAppraisalValue, reportURI);
return artCollection[tokenId].appraisalValue;
}
}
In this section, we’ll learn how to apply Web3.py filters to the front end of a decentralized application.
Since our contract can store data in the event log, we need to build a way to access that historical data. The ERC-721 standard provides the tools needed for managing these events.
Solidity automatically inherits all the code for managing events from the ERC721Full
contract. To access the event data, a filter for the event needs to be created in the front end of a dApp.
Why a filter? The reason is that more than one type of event might get logged for the smart contract. We want to be able to choose the event type that we desire to access. Web3.py offers the createFilter
function that can be used to create the necessary filter.
For the ArtRegistry
contract, the dApp will require using only the fromBlock
and argument_filters
parameters of the Web3.py createFilter
function.
Below is the complete code for the Streamlit dApp front end:
import os
import json
from web3 import Web3
from pathlib import Path
from dotenv import load_dotenv
import streamlit as st
load_dotenv()
# Define and connect a new Web3 provider
w3 = Web3(Web3.HTTPProvider(os.getenv("WEB3_PROVIDER_URI")))
###
# Load_Contract Function
###
@st.cache(allow_output_mutation=True)
def load_contract():
# Load the contract ABI
with open(Path('./contracts/compiled/artregistry_abi.json')) as f:
contract_abi = json.load(f)
# Set the contract address (this is the address of the deployed contract)
contract_address = os.getenv("SMART_CONTRACT_ADDRESS")
# Get the contract
contract = w3.eth.contract(
address=contract_address,
abi=contract_abi
)
return contract
# Load the contract
contract = load_contract()
st.title("Art Registry Appraisal System")
st.write("Choose an account to get started")
accounts = w3.eth.accounts
address = st.selectbox("Select Account", options=accounts)
st.markdown("---")
###
# Register New Artwork
###
st.markdown("## Register new Artwork")
st.markdown("---")
###
# Appraise Art
###
st.markdown("## Appraise Artwork")
tokens = contract.functions.totalSupply().call()
token_id = st.selectbox("Choose an Art Token ID", list(range(tokens)))
new_appraisal_value = st.text_input("Enter the new appraisal amount")
report_uri = st.text_area("Enter notes about the appraisal")
# if st.button("Appraise Artwork"):
# Use the token_id and the report_uri to record the appraisal
st.markdown("---")
###
# Get Appraisals
###
st.markdown("## Get the appraisal report history")
art_token_id = st.number_input("Artwork ID", value=0, step=1)
if st.button("Get Appraisal Reports"):
appraisal_filter = contract.events.Appraisal.createFilter(
fromBlock=0,
argument_filters={"tokenId": art_token_id}
)
appraisals = appraisal_filter.get_all_entries()
for appraisal in appraisals:
report_dictionary = dict(appraisal)
print(report_dictionary)
print(report_dictionary["args"])
We have just built another NFT that is compliant with the ERC-721 standard, complete with on-chain, custom members and several linked token URIs.
We used Solidity events and URIs that allow you to connect both data and a front end to our Solidity smart contract from outside the blockchain.
We created a sophisticated dApp for this contract, but it’s not quite finished.
In this section, we’ll introduce IPFS technology and how it can be used to store immutable, hash-based, content-routed data. IPFS can store large datasets with the same level of integrity as on-chain data.
IPFS stands for InterPlanetary File System. It’s a protocol, a network, and a file system. But what exactly does this mean and how does it all fit together?
For two users to exchange data with one another across the internet, they need a common set of rules for how the information is sent between them; this is a communication protocol.
Communication protocols are usually organized as a protocol suite. For example, the internet protocol suite is widely used today, and of the protocols that make up that suite, HyperText Transfer Protocol, or HTTP, is the foundation for communication.
Another important piece is known as the system's architecture, or how the actual computers within the network can communicate with one another. Traditionally, this is done in a client-server model. IPFS uses a peer-to-peer network model.
Instead of using centralized storage, like a database, IPFS distributes each file across multiple nodes in its own network. That is, it breaks down the file into pieces of data and then distributes the pieces across multiple nodes. It does this by using a custom data structure and rules for storing and retrieving the data pieces.
Participants in the IPFS network can thus share their files. And, smart contracts and dApps can thus store and retrieve their files directly from the nodes that have the data pieces. This means that they store and access their data by using a decentralized technology—without the expense of storing that data on the chain.
One of the most popular ways for dApps to access IPFS services is through the Pinata IPFS pinning service.
Pinata provides a web service, called a gateway, that allows access to files through a web browser without installing IPFS on a computer. This gateway acts as a bridge between any single computer and the IPFS network.
Pinning is the act of storing a file on the decentralized IPFS network. Files can be pinned through either the Pinata webpage or the Pinata API.
With Pinata, files can be hosted on the IPFS network and then referenced by a hash. The hash from the IPFS CID column in Pinata can be used in our dApps and smart contracts to refer to the pinned file. Storing this hash in a smart contract is more memory efficient (that is, it uses less space) than storing the entire file. Less required memory means smaller transaction fees associated with storing information on-chain.
Rather than using the Pinata webpage to upload files, we’ll reconfigure our dApp so that it pins the artwork files and appraisal reports through the Pinata API. The API will also return the hashes for the pinned files. So, the dApp will be able to permanently attach them to the tokens and events in the blockchain.
To ease pinning files and reports from the dApp, we’ll need to build a new Python file that has helper functions for interacting with the Pinata API. A helper function is a regular function that codes some of the complexity that using a service, like the Pinata API, requires. All the code for formatting and sending the Pinata API request will be added to helper functions. These helper functions, with the required data, can be called anytime that we need to use this code.
The final version of the pinata.py
file appears as follows:
import os
import json
import requests
from dotenv import load_dotenv
load_dotenv()
file_headers = {
"pinata_api_key": os.getenv("PINATA_API_KEY"),
"pinata_secret_api_key": os.getenv("PINATA_SECRET_API_KEY"),
}
json_headers = {
"Content-Type": "application/json",
"pinata_api_key": os.getenv("PINATA_API_KEY"),
"pinata_secret_api_key": os.getenv("PINATA_SECRET_API_KEY"),
}
def convert_data_to_json(content):
data = {"pinataOptions": {"cidVersion": 1}, "pinataContent": content}
return json.dumps(data)
def pin_file_to_ipfs(data):
r = requests.post(
"https://api.pinata.cloud/pinning/pinFileToIPFS",
files={'file': data},
headers=file_headers
)
print(r.json())
ipfs_hash = r.json()["IpfsHash"]
return ipfs_hash
def pin_json_to_ipfs(json):
r = requests.post(
"https://api.pinata.cloud/pinning/pinJSONToIPFS",
data=json,
headers=json_headers
)
print(r.json())
ipfs_hash = r.json()["IpfsHash"]
return ipfs_hash
Using the helper functions, we can now pin an artwork file to IPFS, build a JSON metadata file for the artwork, convert the metadata to the JSON string that Pinata requires, and then pin the JSON string to IPFS.
Until next time, here’s a twitter thread summary of this post: