Dive into Web3 Contracts with Hardhat and Node.js Vol.3

Written by vivalaakam | Published 2023/12/07
Tech Story Tags: nextjs | telegram | web3 | nft | telegram-bot | ethereum | smart-contracts | crypto-development

TLDRvia the TL;DR App

This article took longer than expected, and I struggled at almost every step for different reasons. But… stop crying, let's make some code =)

1. Introduction

In this article, we dive into web3 with NTFs, and because simply creating a smart contract with an NFT and deploying it is very dull and has already been partly in previous articles on this topic, I decided to complicate my life and make these same NFTs created from a bot in Telegram.

2. The boring part that everyone skips

An NFT, or Non-Fungible Token, is a unique digital asset that represents ownership of a specific item or piece of content, such as artwork, music, videos, or other forms of creative work, using blockchain technology or, in simple words, a link to your asset (storing the asset itself on the blockchain is costly).

Here are some critical aspects of NFTs:

  1. Digital Ownership: NFTs provide a way for creators to sell digital items with a clear record of ownership. This ownership is secured and verified using blockchain technology, typically on the Ethereum blockchain.

  2. Uniqueness and Scarcity: Each NFT has unique properties that cannot be replicated. This uniqueness can create scarcity, which can drive up the value of the NFT.

  3. Interoperability: NFTs can often be used across different blockchain platforms and applications. This means a digital artwork tokenized as an NFT can be viewed, bought, or sold in various virtual environments and marketplaces.

  4. Provenance and Authenticity: The blockchain ledger records the history of each NFT, including its creation, sale, and previous ownership. This transparency helps verify the authenticity and provenance of the digital asset.

  5. Royalties: NFTs can be programmed to provide ongoing royalties to their original creators. Whenever the NFT is sold to a new owner, a percentage of the sale can automatically go back to the original creator.

Because it is expensive to store assets in the blockchain, and there is a strong desire to show off distributed technologies, hipsters reinvent torrents and call it IPFS.

IPFS, which stands for InterPlanetary File System, is a protocol and peer-to-peer network for storing and sharing data in a distributed file system. IPFS was created to make the web faster, safer, and more open. Here are some key features and concepts associated with IPFS:

  1. Decentralized and Distributed: Unlike traditional centralized file storage systems, IPFS is built on a distributed network. This means files aren't stored in a single location but distributed across multiple network nodes.

  2. Content Addressing: IPFS uses content-based addressing rather than location-based addressing. Each file and piece of content on IPFS is identified by a unique fingerprint called a cryptographic hash. This ensures that users are accessing the content they requested, as the hash of the content verifies its integrity.

  3. Efficient and Resilient: Since IPFS retrieves files from the nearest node rather than a central server, it can lead to faster download speeds and reduced load on any single server. This also makes the system more resilient to censorship and server failures.

  4. Versioning and Linking: IPFS supports versioning and linking, which allows for the creating of complex webs of data with rich relationships and history.

  5. Use Cases: IPFS has a wide range of applications, including decentralized web projects, blockchain data storage, file sharing, and as a replacement for traditional HTTP in specific scenarios.

  6. Compatibility with Blockchain: Its decentralized nature makes IPFS a popular choice for storing data in blockchain applications, enhancing storage capacity and access efficiency.

IPFS is part of a more significant movement towards a decentralized internet, where users have more control over their data and how it functions. It is often associated with decentralized technologies like blockchain and distributed ledger technology.

3. Principles of work

How will our bot work?

  1. When a user sends a message or image, we check if the user added his Ethereum address. We unobtrusively ask you to add it if he hasn't added it. Otherwise, we won’t let you create an NFT.
  2. After we have an address, the user can send us an image, which will be a future NFT.
  3. When we got the image, we uploaded it into IPFS and created a metafile with information about NFT.
  4. This file will be uploaded into IPFS.
  5. After we upload everything, we mint NFT in the contract with a link to a metadata file.
  6. Say, user, that's all done.
  7. Profit

4. Prerequisites

First, we need an account on Telegram

Second, create a bot using @BotFather

Init app with next app (not that it's essential, and you can use express, but I make some space for the future)

npx create-next-app@latest telegram-bot --use-npm

Go to folder

cd telegram-bot

And init hardhat

npx hardhat init

And we have a basis for our project.. So clean and nice

Create .envfile and add into it

NEXT_TELEGRAM_TOKEN=6440257800:AAGY...
NEXT_SEED=qwerty
NEXT_VERIFIER_ADDRESS=
NEXT_NFT_ADDRESS=
NEXT_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
NEXT_JSON_RPC=http://127.0.0.1:8545/
NEXT_CHAIN_ID=1337
NEXT_IPFS_NODE=http://127.0.0.1:5001/

Here is the credential for local development.

NEXT_TELEGRAM_TOKEN can be taken from @BotFather

5. Contracts

5.1 Verification

Here is a simple key-value storage

Create file contracts/Verification.sol with the following content

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;

// Uncomment this line to use console.log
// import "hardhat/console.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract Verification is Ownable {
    mapping(uint => address) public verified;

    constructor() Ownable(msg.sender) {}

    function verify(uint256 _id, address _address) public onlyOwner {
        verified[_id] = _address;
    }
}

and scripts/deploy_verification.js

const hre = require("hardhat");

async function main() {
  const instance = await hre.ethers.deployContract("Verification");
  await instance.waitForDeployment();
  console.log(`Verification Contract deployed to ${await instance.getAddress()}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});4.2 Nft

Deploy it

npx hardhat run scripts/deploy_verification.js --network local

Save contract address into .env file

...
NEXT_VERIFIER_ADDRESS=...
...

5.2 NFT

And here I decided not to bother and created a file through the wizard https://docs.openzeppelin.com/contracts/5.x/wizard. It’s very convenient, but first, I would still recommend that you practice writing such contracts yourself.

Create file contracts/TelegramBot.sol with the following content.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract TelegramBot is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;

    constructor(address initialOwner)
    ERC721("TelegramBot", "TGB")
    Ownable(initialOwner)
    {}

    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }

    // The following functions are overrides required by Solidity.

    function _update(address to, uint256 tokenId, address auth)
    internal
    override(ERC721, ERC721Enumerable)
    returns (address)
    {
        return super._update(to, tokenId, auth);
    }

    function _increaseBalance(address account, uint128 value)
    internal
    override(ERC721, ERC721Enumerable)
    {
        super._increaseBalance(account, value);
    }

    function tokenURI(uint256 tokenId)
    public
    view
    override(ERC721, ERC721URIStorage)
    returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
    public
    view
    override(ERC721, ERC721Enumerable, ERC721URIStorage)
    returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

and scripts/deploy_nft.js

const hre = require("hardhat");

async function main() {
  const signers = await hre.ethers.getSigners();
  console.log("Deploying contracts with the account:", signers[0].address);

  const instance = await hre.ethers.deployContract("TelegramBot", [
    signers[0].address,
  ]);
  await instance.waitForDeployment();
  console.log(`TelegramBot Contract deployed to ${await instance.getAddress()}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Deploy it

npx hardhat run scripts/deploy_nft.js --network local

Save the contract address into .env file

...
NEXT_NFT_ADDRESS=...
...

6. Telegram webhook

For local testing, I strongly recommend you to use ngrok

6.1 Preparement.

Create app/api/telegram/route.ts with the following content.

import { TelegramRequest } from "@/app/types";
import { ok } from "@/app/helpers/ok";

export async function POST(request: Request) {
  const reqData = (await request.json()) as TelegramRequest;
  console.log(reqData);

  return ok();
}

app/helpers/ok.ts

import { NextResponse } from "next/server";

export function ok() {
  return new NextResponse("Ok", {
    status: 200,
    statusText: "Ok",
    headers: {
      "Content-Type": "text/plain",
    },
  });
}

helpers for interact with Telegram: app/helpers/tg.ts

const sendMessageUrl = `https://api.telegram.org/bot${process.env.NEXT_TELEGRAM_TOKEN}/sendMessage`;
const fileUrl = `https://api.telegram.org/bot${process.env.NEXT_TELEGRAM_TOKEN}/getFile?file_id=`;
const downloadFileUrl = `https://api.telegram.org/file/bot${process.env.NEXT_TELEGRAM_TOKEN}/`;

export async function sendMessage(chat_id: String | number, message: String) {
  const req = await fetch(sendMessageUrl, {
    method: "POST",
    body: JSON.stringify({
      chat_id: chat_id,
      text: message,
      parse_mode: "markdown",
    }),
    headers: {
      "Content-Type": "application/json",
    },
  });

  const resp = await req.json();

  console.log("sendMessage", resp);

  return resp;
}

export async function getFile(file_id: String) {
  const req = await fetch(`${fileUrl}${file_id}`, {
    method: "GET",
  });

  const resp = await req.json();

  console.log("getFile", resp);

  return resp;
}

export async function downloadFile(file_path: String) {
  const req = await fetch(`${downloadFileUrl}${file_path}`, {
    method: "GET",
  });

  return await req.blob();
}

Helpers for interact with IPFS: app/helpers/ipfs.ts

export async function uploadFile(
  filename: string,
  data: Blob,
): Promise<{ Name: string; Hash: string; Size: string }> {
  const form = new FormData();
  form.append(filename, data, filename);

  const uploadResponse = await fetch(
    `${process.env.NEXT_IPFS_NODE}api/v0/add?pin=true`,
    {
      method: "POST",
      body: form,
      headers: {
        "accept-type": "application/json",
      },
    },
  );

  const resp = await uploadResponse.json();
  console.log(`upload ${filename} response:`, resp);

  return resp;
}

app/types.ts

export type TelegramPhotoRequest = {
  file_id: string;
  file_unique_id: string;
  file_size: number;
  width: number;
  height: number;
};

export type TelegramEntityRequest = {
  offset: number;
  length: number;
  type: string;
};

export type TelegramRequest = {
  update_id: number;
  message: {
    message_id: number;
    from: {
      id: number;
      is_bot: boolean;
      first_name: string;
      last_name: string;
      username: string;
      language_code: string;
      is_premium: boolean;
    };
    chat: {
      id: number;
      first_name: string;
      last_name: string;
      username: string;
      type: string;
    };
    date: number;
    text: string;
    photo: TelegramPhotoRequest[] | undefined;
    entities: TelegramEntityRequest[] | undefined;
    caption: string | undefined;
  };
};

Next steps will be in app/api/telegram/route.ts file

6.2 Initialise provider for work with Ethereum network.

import { ethers, JsonRpcProvider } from "ethers";
import { TelegramRequest } from "@/app/types";
import { ok } from "@/app/helpers/ok";

export async function POST(request: Request) {
  const reqData = (await request.json()) as TelegramRequest;
  console.log(reqData);

  const provider = new JsonRpcProvider();
  const signer = new ethers.Wallet(
    process.env.NEXT_PRIVATE_KEY as string,
    provider,
  );

  return ok();
}

6.3 Initialise verification contract

...

const verifierAbi =
  require("@/artifacts/contracts/Verification.sol/Verification.json").abi;

export async function POST(request: Request) {
  ... 
  const verifierContract = new ethers.Contract(
    process.env.NEXT_VERIFIER_ADDRESS as string,
    verifierAbi,
    signer,
  );

  return ok();
}

6.4 Check user is registered.

We need to make a hash of the user ID to preserve his anonymity. As we already know, the blockchain remembers everything, and it will not be very good for the user if we store in clear form a link between the user’s account and his telegram identifier.

...
import * as crypto from "crypto";
...

export async function POST(request: Request) {

	...

  const hash = crypto
    .createHash("sha256")
    .update(`${process.env.NEXT_SEED}:${reqData.message.from.id}`)
    .digest();

  let address = null;

  try {
    address = await verifierContract.verified("0x" + hash.toString("hex"));
  } catch (e) {
    console.log("address", e);
  }

	if (!address || address === "0x0000000000000000000000000000000000000000") {
	
	}

  return ok();
}

6.5 Registration.

If the user is not registered yet, we check the message. If it is the correct address - make registration; otherwise, send a message that we need his address.

...
import { isAddress } from "ethers";
import { ok } from "@/app/helpers/ok";
import { sendMessage } from "@/app/helpers/tg";
...

export async function POST(request: Request) {
  ...

  if (!address || address === "0x0000000000000000000000000000000000000000") {
    if (isAddress(reqData.message.text)) {
      let r = await verifierContract.verify(
        "0x" + hash.toString("hex"),
        reqData.message.text.trim(),
      );
      console.log("resp", r);

      let address = await verifierContract.verified(
        "0x" + hash.toString("hex"),
      );

      const resp = await sendMessage(
        reqData.message.chat.id,
        `${address} registered\n now you can send NFTs`,
      );
      console.log("resp 1", resp);
      return ok();
    }

    const resp = await sendMessage(reqData.message.chat.id, "need authorize");
    console.log("resp 2", resp);
    return ok();
  }

  return ok();
}

6.6 Validate photo.

If user doesn’t sent to us a photo, we need to talk about it

...

export async function POST(request: Request) {
  ...

  if (!reqData.message.photo) {
    const resp = await sendMessage(reqData.message.chat.id, `not found photos`);
    console.log("resp validate", resp);
    return ok();
  }
  return ok();
}

6.7 Download photo.

If all checks are passed, download the sent photo


import { downloadFile, getFile } from "@/app/helpers/tg";

export async function POST(request: Request) {
  ...
  let photo = reqData.message.photo[reqData.message.photo.length - 1];

  let file = await getFile(photo.file_id);
  console.log("file", file);

  let data = await downloadFile(file.result.file_path);

  return ok();
}

6.8 Save to IPFS.

...
import { uploadFile } from "@/app/helpers/ipfs";
...
export async function POST(request: Request) {
  ...
  const respNft = await uploadFile("image", data);
  console.log("respNft", respNft);

  const respMetadata = await uploadFile(
    "metadata.json",
    new Blob(
      [
        JSON.stringify({
          name: reqData.message.caption,
          image: `ipfs://${respNft.Hash}`,
        }),
      ],
      { type: "application/json" },
    ),
  );

  return ok();
}

6.9 mint users NFT.

After we save all data into IPFS, we can mint NFT.

const nftAbi =
  require("@/artifacts/contracts/TelegramBot.sol/TelegramBot.json").abi;

export async function POST(request: Request) {
  ...

  const nftContract = new ethers.Contract(
    process.env.NEXT_NFT_ADDRESS as string,
    nftAbi,
    signer,
  );

  const mint = await nftContract.safeMint(
    address,
    `ipfs://${respMetadata.Hash}`,
  );

  console.log("mint", mint);

  const respMint = await sendMessage(
    reqData.message.chat.id,
    `tx hash: ${mint.tx_hash}`,
  );
  console.log("resp mint", respMint);

  return ok();
}

Done =)

7. Conclusion

This is the simplest possible implementation, which has some usability problems but is enough to understand the principles of operation. There are plans to make a web version using Metamask, which NEXT was used for. I tried not to use libraries to communicate with Telegram and IPFS so that there would be more understanding of how it works and you can install the library very quickly. I hope this will be enough to understand the principles of operation. The source code is on GitHub.


Written by vivalaakam | Enthusiast
Published by HackerNoon on 2023/12/07