Hi fellow Rustaceans! 🦀
In today’s article, we will implement a full Rust API for minting NFTs using Ethereum Blockchain, decentralized file storage integration with IPFS, and the Smart Contract implementation in Solidity.
By the end of the article, you’ll be able to interact with the API using swagger-ui, gaining foundational knowledge about how to put together Web3, RESTful Rust API, Ethereum Blockchain, and Smart Contracts with Solidity.
I hope you find this deep dive into the Rust NFT API both informative and engaging, even though it turned out to be a bit longer than our usual reads. For those who prefer a more hands-on approach or would like to explore the code further, I’ve got good news!
🚀 All the details, including the complete codebase and step-by-step instructions to get the project up and running, are neatly organized in the GitHub repository at https://github.com/luishsr/rust-nft-api. Feel free to jump right in, and happy coding!
Let’s dive right in!
Project Structure Overview
The project is structured in the following way:
rust-nft-api/
├── contract/
│ └── MyNFT.sol
├── nft-images/
│ └── token.jpg
├── src/
│ ├── main.rs
│ ├── error.rs
│ ├── ipfs.rs
│ ├── model.rs
│ ├── utils.rs
│ └── web3client.rs
├── static/
│ └── swagger-ui/
├── .env
└── Cargo.toml
contract/: Contains the Solidity smart contract (MyNFT.sol) for the NFT, defining the rules for minting and transferring the NFTs.nft-images/: Stores the images or assets associated with each NFT, which are referenced in the NFT metadata.src/: The source directory where the Rust files reside, each serving a specific purpose in the API functionality:main.rs: The entry point of the API, setting up the server and routes.error.rs: Defines custom error handling for the API.ipfs.rs: Handles interaction with IPFS for storing off-chain metadata.model.rs: Defines the data models used by the API, including structures for NFTs and metadata.utils.rs: Contains utility functions used across the project.web3client.rs: Manages the communication with the Ethereum blockchain using Web3.static/: Contains static files, such as the Swagger UI for API documentation..env: A dotenv file for managing environment variables, such as API keys and blockchain node URLs.Cargo.toml: The Rust package manifest file, listing dependencies and project information.
Key Components and Functionalities
Smart Contract (MyNFT.sol)
The smart contract is written in Solidity and deployed to the Ethereum blockchain. It defines the rules for minting, transferring, and managing the NFTs according to the ERC-721 standard, which is a widely used standard for NFTs on Ethereum.
IPFS Integration (ipfs.rs)
IPFS, or InterPlanetary File System, is used to store off-chain metadata for NFTs. This ensures that the metadata, including images and descriptive information, is decentralized and tamper-proof. The ipfs.rs module handles the uploading and retrieval of metadata to and from IPFS.
Web3 Client (web3client.rs)
This module establishes a connection to the Ethereum blockchain using the Web3 library. It enables the API to interact with the blockchain, performing actions such as minting NFTs, retrieving NFT details, and listening to blockchain events.
API Endpoints (main.rs)
The main.rs file sets up the RESTful API server and defines the routes for various endpoints, such as creating NFTs, fetching NFT details by token ID, and listing all NFTs. It uses the Actix-web framework for handling HTTP requests and responses.
Error Handling and Utilities (error.rs, utils.rs)
Proper error handling is crucial for a robust API. The error.rs module defines custom error types and handling mechanisms to ensure clear and helpful error messages are returned to the client. The utils.rs module contains utility functions that support various operations within the API, such as data validation and formatting.
Step 1. Smart Contract Implementation
The MyNFT contract, developed in Solidity, extends the ERC721URIStorage contract from OpenZeppelin, a standard library for secure blockchain development. It leverages the ERC721 protocol, a popular standard for representing ownership of NFTs, and adds the capability to associate NFTs with URI-based metadata.
Key Components
- Token Counters: Utilizes OpenZeppelin’s
Countersutility to maintain a unique identifier for each NFT minted. - Token Details Structure: Defines a
TokenDetailsstruct to hold essential information about each NFT, including its ID, name, owner, and associated URI. - Mappings: Three primary mappings are used to track NFT ownership and details:
_tokenDetailsmaps each token ID to itsTokenDetails._ownedTokensmaps an owner's address to a list of token IDs they own._ownedTokensIndexmaps a token ID to its position in the owner's list of tokens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract MyNFT is ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
struct TokenDetails {
uint256 tokenId;
string tokenName;
address tokenOwner;
string tokenURI;
}
mapping(uint256 => TokenDetails) private _tokenDetails;
mapping(address => uint256[]) private _ownedTokens;
mapping(uint256 => uint256) private _ownedTokensIndex; // Maps token ID to its index in the owner's token list
constructor() ERC721("MyNFT", "MNFT") {}
function mintNFT(address recipient, string memory tokenName, string memory tokenURI) public returns (uint256) {
_tokenIds.increment();
uint256 newItemId = _tokenIds.current();
_mint(recipient, newItemId);
_setTokenURI(newItemId, tokenURI);
_tokenDetails[newItemId] = TokenDetails({
tokenId: newItemId,
tokenName: tokenName,
tokenOwner: recipient,
tokenURI: tokenURI
});
_addTokenToOwnerEnumeration(recipient, newItemId);
return newItemId;
}
private function _addTokenToOwnerEnumeration(address to, uint256 tokenId) {
_ownedTokens[to].push(tokenId);
_ownedTokensIndex[tokenId] = _ownedTokens[to].length - 1;
}
function getAllTokensByOwner(address owner) public view returns (uint256[] memory) {
if (owner == address(0)) {
uint256 totalTokens = _tokenIds.current();
uint256[] memory allTokenIds = new uint256[](totalTokens);
for (uint256 i = 0; i < totalTokens; i++) {
allTokenIds[i] = i + 1; // Token IDs are 1-indexed because of the way they are minted
}
return allTokenIds;
} else {
return _ownedTokens[owner];
}
}
function getTokenDetails(uint256 tokenId) public view returns (uint256, string memory, address, string memory) {
require(_ownerOf(tokenId) != address(0), "ERC721: Query for nonexistent token");
TokenDetails memory tokenDetail = _tokenDetails[tokenId];
return (tokenDetail.tokenId, tokenDetail.tokenName, tokenDetail.tokenOwner, tokenDetail.tokenURI);
}
}
Step 2. Writing the Web3Client
The web3client.rsfile contains the implementation of the Web3Client struct, which encapsulates the functionality needed to interact with smart contracts on the Ethereum network using the Rust programming language. Below, we delve into the key aspects of this implementation.
Web3Client Structure
The Web3Client struct contains two main fields:
web3: An instance of theWeb3type, representing a connection to an Ethereum node.contract: AContractinstance, representing the smart contract on the Ethereum blockchain that the API will interact with.
Implementation Details
- The
newFunction: This is a constructor for theWeb3Clientstruct. It initializes a newWeb3instance and a newContractinstance with the provided smart contract address. - Ethereum Node Connection: It establishes an HTTP connection to an Ethereum node specified by the
ETH_NODE_URLenvironment variable. This connection is essential for sending transactions and making calls to the Ethereum blockchain. - Smart Contract ABI: The ABI (Application Binary Interface) of the smart contract is necessary for the Rust application to understand how to interact with the contract. The ABI is loaded from a file specified by the
CONTRACT_ABI_PATHenvironment variable. This ABI file is usually generated by the Solidity compiler when the smart contract is compiled. - Contract Initialization: With the ABI and the smart contract’s address, a new
Contractinstance is created. This instance allows the Rust application to call functions of the smart contract, listen to events emitted by it, and query its state.
use std::env;
use std::error::Error;
use web3::contract::Contract;
use web3::transports::Http;
use web3::{ethabi, Web3};
pub struct Web3Client {
pub web3: Web3<Http>,
pub contract: Contract<Http>,
}
impl Web3Client {
pub fn new(contract_address: &str) -> Result<Self, Box<dyn Error>> {
let http = Http::new(&env::var("ETH_NODE_URL")?)?;
let web3 = Web3::new(http);
let contract_abi_path = env::var("CONTRACT_ABI_PATH")?;
let contract_abi_file = std::fs::File::open(contract_abi_path)?;
let contract_abi: ethabi::Contract = serde_json::from_reader(contract_abi_file)?;
let contract = Contract::new(web3.eth(), contract_address.parse()?, contract_abi);
Ok(Web3Client { web3, contract })
}
}
Step 3. Data Structure
The model.rs file within the Rust NFT API project defines several key data structures using Rust's powerful type system, combined with serialization capabilities provided by serde, and API documentation features from utoipa.
use serde::{Deserialize, Serialize};
use utoipa::Component;
#[derive(Serialize, Deserialize, Component)]
pub struct MintNftRequest {
pub(crate) owner_address: String,
pub(crate) token_name: String,
pub(crate) token_uri: String,
pub(crate) file_path: String,
}
#[derive(Serialize, Deserialize, Component)]
pub struct TokenFileForm {
file: Vec<u8>,
}
#[derive(Serialize, Deserialize, Component)]
pub struct ApiResponse {
pub(crate) success: bool,
pub(crate) message: String,
pub(crate) token_uri: Option<String>,
}
#[derive(Serialize, Deserialize, Component)]
pub struct NftMetadata {
pub(crate) token_id: String,
pub(crate) owner_address: String,
pub(crate) token_name: String,
pub(crate) token_uri: String,
}
#[derive(Serialize, Deserialize)]
pub struct UploadResponse {
token_uri: String,
}
Click Here to Learn More
MintNftRequest
This structure represents the request body for minting a new NFT. It contains fields for the owner’s address, the token’s name, the token’s URI (which points to the metadata or asset associated with the NFT), and the file path to the asset to be associated with the NFT. The use of pub(crate) makes these fields accessible within the crate.
TokenFileForm
Defines the data structure for a file upload form, specifically for uploading files associated with NFTs. The file field is a vector of bytes (Vec<u8>), representing the binary content of the file being uploaded.
ApiResponse
A generic API response structure that can be used to communicate the outcome of various API operations. It includes a success flag indicating whether the operation was successful, a message providing additional information or error details, and an optional token_uri which is especially relevant in operations involving NFTs, where a URI pointing to the NFT's metadata or asset might be returned.
NftMetadata
Represents the metadata associated with an NFT. It includes the token_id, owner_address, token_name, and token_uri. This model is crucial for operations that involve retrieving or displaying NFT details.
UploadResponse
Specifically tailored for file upload operations, this model captures the response of an upload operation, primarily containing the token_uri of the uploaded file. This URI can then be used in the minting process or for other purposes that require a reference to the uploaded asset.
Step 4. Interfacing with IPFS
The ipfs.rs module within the Rust NFT API project is dedicated to handling interactions with the InterPlanetary File System (IPFS), a decentralized storage solution. This module facilitates the uploading of files to IPFS, which is crucial for storing off-chain NFT metadata or assets.
use crate::model::ApiResponse;
use axum::Json;
use reqwest::Client;
use serde_json::Value;
use std::convert::Infallible;
use std::env;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
pub async fn file_upload(file_name: String) -> Result<Json<ApiResponse>, Infallible> {
let client = Client::new();
let ipfs_api_endpoint = "http://127.0.0.1:5001/api/v0/add";
// Get the current directory
let mut path = env::current_dir().expect("Failed to get current directory");
// Append the 'nft-images' subdirectory to the path
path.push("nft-images");
// Append the file name to the path
path.push(file_name);
//println!("Full path: {}", path.display());
// Open the file asynchronously
let mut file = File::open(path.clone()).await.expect("Failed to open file");
// Read file bytes
let mut file_bytes = Vec::new();
file.read_to_end(&mut file_bytes)
.await
.expect("Failed to read file bytes");
// Extract the file name from the path
let file_name = path
.file_name()
.unwrap()
.to_str()
.unwrap_or_default()
.to_string();
let form = reqwest::multipart::Form::new().part(
"file",
reqwest::multipart::Part::stream(file_bytes).file_name(file_name),
);
let response = client
.post(ipfs_api_endpoint)
.multipart(form)
.send()
.await
.expect("Failed to send file to IPFS");
if response.status().is_success() {
let response_body = response
.text()
.await
.expect("Failed to read response body as text");
let ipfs_response: Value =
serde_json::from_str(&response_body).expect("Failed to parse IPFS response");
let ipfs_hash = format!(
"https://ipfs.io/ipfs/{}",
ipfs_response["Hash"].as_str().unwrap_or_default()
);
Ok(Json(ApiResponse {
success: true,
message: "File uploaded to IPFS successfully.".to_string(),
token_uri: Some(ipfs_hash),
}))
} else {
Ok(Json(ApiResponse {
success: false,
message: "IPFS upload failed.".to_string(),
token_uri: None,
}))
}
}
Process Flow
Initialization: A Client instance from Reqwest is created for making HTTP requests.
File Path Construction: The function constructs the file path by combining the current working directory, a nft-images subdirectory, and the provided file_name.
File Reading: Opens and reads the specified file asynchronously, collecting its bytes into a vector.
Form Preparation: Prepares a multipart form containing the file bytes, using the file’s name as part of the form data.
IPFS API Request: Sends the multipart form to the IPFS node’s add endpoint (/api/v0/add) via a POST request.
Response Handling:
- On success, parses the IPFS response to extract the file’s IPFS hash, constructs a URL to access the file via an IPFS gateway, and creates a successful
ApiResponsecontaining this URL. - On failure, constructs an
ApiResponseindicating the upload failure.
Step 5. Error Handling
The error.rs file is dedicated to defining and managing various error types that can occur during the application's operation. This module uses the thiserror crate for defining custom error types and the axum framework for mapping these errors to appropriate HTTP responses. Here's an overview of how error handling is structured within this file:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
// Define a custom application error type using `thiserror`
#[derive(Error, Debug)]
pub enum AppError {
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Internal server error: {0}")]
InternalServerError(String),


