DEV Community

popoola rahmat
popoola rahmat

Posted on

My first ink! full-stack project: From smart contract to frontend connection with PAPI.

INTRODUCTION

There’s always a beginning for every milestone, and this was mine.

When I first heard about ink!, the Rust-based smart contract language for the Polkadot ecosystem, it felt intimidating. The syntax looked different, the tools were unfamiliar, and the entire Substrate environment seemed complex.
At one point, I even paused my learning; it all felt overwhelming. But with proper mentorship, everything changed.
In less than two weeks, I learned more than I thought possible.
That single step became the beginning of one of the most rewarding learning experiences I’ve ever had.

In this article, I’ll take you through my first ink! full-stack project, a PSP token contract, much like your first ERC-20 contract for people with a Solidity background.
We’ll deploy it using the substrate contracts UI and then connect it to a Next.js frontend using Polkadot API (PAPI) and the ink! SDK.

And trust me, this is a complete beginner-friendly guide, built the same way I achieved mine.

1. Setting Up ink!

I followed the official ink! v6 setup guide: use.ink Docs, Getting Started (v6)

You’ll need:

Once installed, create your first ink! project with:

cargo contract new psp_token
Enter fullscreen mode Exit fullscreen mode

This command scaffolds a new ink! smart contract named psp_token, generating a preconfigured folder structure for you.

After running the command, you’ll get a directory like this:

psp_token/
│
├── Cargo.toml
├── lib.rs
Enter fullscreen mode Exit fullscreen mode

2. Writing the Smart Contract

Here’s my ink! contract, a simple PSP token, it's the same as the normal OpenZeppelin ERC standard, and the functions are what are implemented. It supports transfer, transfer_from, approve, increase_allowance, decrease_allowance, token_name, token_symbol, token_decimals, mint, and burn functions all in the lib.rs

lib.rs

#![cfg_attr(not(feature = "std"), no_std, no_main)]

use ink::prelude::string::String;

#[derive(Debug, PartialEq, Eq)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
#[allow(clippy::cast_possible_truncation)]
pub enum PSP22Error {
    Custom(String),
    InsufficientBalance,
    InsufficientAllowance,
    ZeroRecipientAddress,
    ZeroSenderAddress,
    SafeTransferCheckFailed(String),
}

#[ink::contract]
mod inkerc20 {
    use super::PSP22Error;
    use ink::prelude::string::{ String, ToString };
    use ink::storage::Mapping;
    use ink::primitives::H160;

    /// Defines the storage of your contract.
    /// Add new fields to the below struct in order
    /// to add new static storage fields to your contract.
    #[ink(storage)]
    pub struct PspCoin {
        /// Total token supply.
        total_supply: u128,
        /// Mapping from owner to number of owned token.
        balances: Mapping<H160, u128>,
        /// Mapping of the token amount which an account is allowed to withdraw
        /// from another account.
        allowances: Mapping<(H160, H160), u128>,
        /// Token name
        name: Option<String>,
        /// Token symbol
        symbol: Option<String>,
        /// Token decimals
        decimals: u8,
    }
Enter fullscreen mode Exit fullscreen mode

complete code here, which also has unit tests and comment line to explain what each functions does, copy and paste in your lib.rs file Or, if you’d rather skip manual deployment, you can deploy your own PSP token directly using my live frontend: https://psp-token-frontend.vercel.app/, Choose your constructor parameters, for example: total_supply: 100000, name, symbol, and decimals as desired.

Each #[ink(message)] function can be called from the frontend once deployed.

3. Building and Generating Metadata

Before connecting our contract to the frontend, we need to build the contract and generate its metadata.

The build process compiles your Rust code to RISC-V, the format used by the Substrate blockchain, while the metadata file describes the contract’s structure (methods, types, and interfaces).
This metadata is essential when deploying your contract or interacting with it from a frontend application.

To build and generate metadata, run:

cargo contract build
Enter fullscreen mode Exit fullscreen mode

This command does three things:

  • Compiles your smart contract.
  • It generates a .contract, .json, and .polkavm file, a bundle that contains both the PolkaVM-compatible binary and the metadata required for deployment and frontend interaction.
  • Places the output inside the target/ink/ folder.

4. Deploying the Contract

You can deploy your contract using the Substrate Contracts UI

Steps:

  • Pick a network. For this project, I used PassetHub on the Paseo Testnet. You can connect to it through the network selector at the top of the page, then click “Upload Contract.”

  • Select your .contract file, which contains the compiled PolkaVM code and metadata, and click Next

  • Choose your constructor parameters, for example: total_supply: 100000, name, symbol, and decimals as desired.

  • Deploy on testnet by clicking on upload and instantiate.
  • Copy your contract address; you’ll need it later when connecting your frontend. Congratulations, your contract is on-chain

Your smart contract is only half the story.
The real deal happens when your frontend starts talking to the blockchain, fetching balances, sending transactions, and deploying contracts right from the browser
.

5. Setting Up the Frontend

Now that our smart contract is built and deployed, the next step is to connect it to a frontend, the part users actually interact with.

For this project, I didn’t use any pre-built templates; I started from scratch using Next.js and Tailwind CSS.
That choice helped me understand every setup piece, and honestly, it was worth it. (Looking forward to using a boilerplate next time)

Project setup

# Create a new Next.js app
npx create-next-app psp-frontend

# Move into the project directory
cd psp-frontend
Enter fullscreen mode Exit fullscreen mode

Installing dependencies
We’ll install a few packages to help our frontend communicate with the blockchain, connect wallets, and interact with our ink! contract.

# Core Polkadot API SDK
npm i polkadot-api

# Wallet connection (for Talisman, Subwallet, Polkadot.js)
npm install @talismn/connect-wallets

# ink! SDK for frontend contract interaction
npm i @polkadot-api/sdk-ink

# Add our testnet endpoint (Passet Hub)
npx papi add -w wss://testnet-passet-hub.polkadot.io passet

# Signer and crypto utilities
npm install @polkadot-api/pjs-signer @polkadot/util-crypto @polkadot/util
Enter fullscreen mode Exit fullscreen mode

After completing these installations, the frontend is now ready to connect directly to the blockchain and interact with the deployed PSP token contract.

6. Preparing the Frontend for Smart Contract Interaction

Now that our dependencies are installed, it’s time to prepare the frontend for smart contract interaction.

This step is about setting up our folder structure, adding the compiled contract bundle, and generating metadata so that our frontend knows how to connect to the deployed contract on-chain.

  • Folder Structure Setup Inside your project’s root directory (psp-frontend/), create the following folders:
mkdir deployment_data
mkdir -p src/{app,components,contexts,utils}
Enter fullscreen mode Exit fullscreen mode

The -p flag automatically creates all folders in one command.

Your folder structure should now look like this:
psp-frontend/

├── .papi/
├── deployment_data/
├── public/
├── src/
│ ├── app/
│ ├── components/
│ ├── contexts/
│ └── utils/
└── package.json

Let’s briefly explain what each folder does:

Folder Purpose
.papi/ Stores automatically generated metadata by the Polkadot API (PAPI).
deployment_data/ Stores your compiled .contract file (the smart contract bundle).
public/ Holds static assets like images or icons.
src/app/ Contains your main pages (e.g., page.tsx, layout.tsx).
src/components/ Reusable UI components like Navbar or Buttons.
src/contexts/ Handles Web3 logic such as wallet connection and contract client setup.
src/utils/ Helper functions or configurations.
  • Copy Your Contract Bundle

After building your ink! contract with: cargo contract build You’ll find the compiled contract file inside your ink! project folder at: target/ink/psp_token.contract, Copy that .contract file into your deployment_data folder in the frontend project, so you will have /deployment_data/psp.contract

This .contract file contains:

  • The compiled code
  • The contract metadata (ABI, constructors, functions, etc.)
  • Everything needed for frontend interaction

Generate TypeScript bindings with PAPI

Now, navigate to the frontend directory and run the following:

npx papi ink add "deployment_data/psp.contract"

Enter fullscreen mode Exit fullscreen mode

This command is used when you already have a .metadata.json file generated separately from your build process (for example, if you extracted only the metadata file).
It tells PAPI to add this metadata into its local registry (in .papi/) so it can generate TypeScript bindings for it.

By now, your frontend knows what contract it’s connecting to and how to communicate with it.
We’ve laid the foundation for the next crucial step, actually connecting the wallet and initialising the Ink SDK

6. Connecting Wallets and Initializing the Ink Client

Before interacting with our deployed contract, the frontend must first connect to a user’s wallet and initialize a client that can communicate with the blockchain.

In my project, I placed this logic inside the src/contexts folder to keep all blockchain setup separated from UI components. This helps maintain a clean architecture and allows different parts of the app to access shared data like wallet info and contract instances easily.

Our contexts folder looks like this:

src/
 └── contexts/
      ├── wallet-provider.tsx
      ├── ink-client.tsx
      └── types.ts
Enter fullscreen mode Exit fullscreen mode

Before any wallet can connect or any contract can be called, we need to define the TypeScript “blueprint” for how data flows across our dApp. In this project, our types.ts file lives inside the src/contexts/ folder and defines two main contexts:

WalletContext → handles everything about wallet connections and account management.
InkClientContext → handles blockchain communication and smart contract interactions. Here’s the full code. It makes a complete file with just 3 steps

  1. I import ready-made types from the Polkadot and Talisman SDKs:
import type { Wallet, WalletAccount } from "@talismn/connect-wallets";
import type { Binary, PolkadotClient } from "polkadot-api";
Enter fullscreen mode Exit fullscreen mode

These ensure that when we talk about wallets, accounts, or clients, TypeScript already knows what structure to expect.

  1. WalletContextType This defines all the wallet-related actions the dApp supports:
Property Description
connect(wallet) Connects to a specific wallet like Talisman or Polkadot.js
disconnect() Disconnects the current wallet
wallets List of all detected wallets on the user’s browser
isConnecting Tracks connection state (string when connecting, false when idle)
activeWallet Currently connected wallet instance
accounts All accounts available from the connected wallet
selectedAccount The currently active user account
switchAccount(account) Allows switching between multiple accounts

And finally create a React Context for it:

export const WalletContext = createContext<WalletContextType | null>(null);
Enter fullscreen mode Exit fullscreen mode

This will allow any component in the dapp to access wallet data through useContext(WalletContext).

  1. InkClientContextType it defines everything related to smart contract interaction. Let’s break it into logical groups: Connection
client: PolkadotClient | null;

Enter fullscreen mode Exit fullscreen mode

This represents your live connection to the Polkadot network.

  • Queries (Read-only)
fetchTokenInfo()
fetchTokenSupply()
fetchTokenBalance()
fetchAllowance()
Enter fullscreen mode Exit fullscreen mode

These functions use .call() under the hood, meaning they read data from the contract but don’t change the blockchain state.

  • Transactions (Write)
deploy()
transferToken()
transferFromToken()
approveToken()
mintToken()
burnToken()
Enter fullscreen mode Exit fullscreen mode

These use .send() and require a signed wallet transaction. Each one modifies blockchain state (for example, transferring tokens, minting new ones, or approving another account).
And like before, we create the React context:

Not calling any blockchain functions here, it's just to tell TypeScript what to expect later.
When you import these types into wallet-provider.tsx and ink-client.tsx, they become the “rules” your app follows, preventing mistakes while interacting with wallets and contracts.

After defining our types in types.ts, the next step is to bring wallet interaction to life.
This file is responsible for connecting wallets, managing accounts, and providing wallet data globally through React Context.
Here’s the full code

ink-client.tsx is the frontend bridge between React and Ink! smart contract deployed on a Substrate-based chain (in this case, testnet-passet-hub.polkadot.io). you can get the full code here

Every function here talks to the blockchain, either reading or writing data.

The key pattern is this:

Action Type Method Used Description
Read from chain .query() Fetches data from the smart contract. It’s free, no gas fee and doesn’t modify blockchain state.
Write to chain .send().signAndSubmit() Executes a transaction. It costs gas and permanently updates the blockchain state.
  • Example 1: .query (Read-Only Calls)
const fetchTokenSupply = useCallback(async (account: WalletAccount) => {
  const cl = client ?? (await initializeClient());
  const inkClient = createInkSdk(cl);
  const p2pContract = inkClient.getContract(contracts.ink_cross, contract_address);

  const originAddress = account.address;
  const result = await p2pContract.query("total_supply", { origin: originAddress });

  if (result.success) {
    return result.value.response as bigint;
  }
}, [client, initializeClient]);

Enter fullscreen mode Exit fullscreen mode
  • You’re using p2pContract.query() to ask the contract for data.
  • These are “read-only” calls.
  • They don’t need signing, don’t cost gas, and don’t change state.
  • You only pass: origin: who’s asking (for context) and data: optional arguments (like owner, spender), it depends on the argument the deployed function takes

Note: If you only need to see something, like balance, allowance, name, symbol, then you should use .query.

  • Example 2: .send (Transactions) Now check this one, your transferToken function:
const transferToken = useCallback(
  async (to: Binary, amount: bigint, account: WalletAccount) => {
    const cl = client ?? (await initializeClient());
    const inkClient = createInkSdk(cl);
    const pspContract = inkClient.getContract(contracts.ink_cross, contract_address);

    const signer = await getPolkadotSigner(account);
    if (signer) {
      const result = await pspContract
        .send("transfer", {
          origin: account.address,
          data: { to, value: amount },
        })
        .signAndSubmit(signer);

      if (result.ok) {
        console.log("Transfer successful:", result);
      } else {
        console.error("Transfer failed:", result);
      }
    }
  },
  [client, initializeClient]
);

Enter fullscreen mode Exit fullscreen mode
  • used .send("transfer", …) instead of .query.
  • Then called .signAndSubmit(signer),this means:
  • You sign the transaction (proving ownership of the account)
  • You submit it to the blockchain for inclusion in a block.
  • This actually moves tokens from one account to another.
  • It costs gas.
  • It changes the blockchain state (balances update).

Note: If the function changes something (transfer, approve, mint, burn, etc.), use .send and .signAndSubmit.

  • So When You’re Writing a New Function You can now follow this logic every time; it's the same pattern
"use client";

import { useWallet } from "./wallet-provider";
import { createInkSdk, initializeClient } from "@polkadot-api/ink";
import { getPolkadotSigner } from "@polkadot-api/polkadot-signer";
import contracts from "@/contracts.json"; // JSON mapping of contract names to metadata
import { contract_address } from "@/config";

export const useInkClient = () => {
  const { client, account } = useWallet();

  // Step 1: Connect to the chain client
  const connectToClient = async () => {
    // If the client from context already exists, reuse it
    const cl = client || (await initializeClient());

    // Create an ink! SDK instance, which provides access to contracts
    const inkClient = createInkSdk(cl);

    // Access our deployed contract by name and address
    // - `contracts.ink_cross` comes from your contracts.json file
    // - `contract_address` is the deployed address of your ink! contract
    const myContract = inkClient.getContract(contracts.ink_cross, contract_address);

    return { myContract };
  };

  // Step 2a: Perform a read (query) operation
  const readAllowance = async (owner: string, spender: string) => {
    const { myContract } = await connectToClient();

    // A query does not change the blockchain state — it just reads data
    // origin → the account making the query (must be connected)
    // data → arguments your contract function expects
    const result = await myContract.query("allowance", {
      origin: account.address,
      data: { owner, spender },
    });

    // The return value is fetched directly from the contract without gas or fees
    return result;
  };

  // Step 2b: Perform a write (send) operation
  const approveSpender = async (spender: string, value: bigint) => {
    const { myContract } = await connectToClient();

    // A write (send) changes the contract state — it requires a signer
    const signer = await getPolkadotSigner(account);

    // send() submits a transaction to the blockchain
    // origin → the user's wallet address sending the transaction
    // data → arguments to the contract function
    // .signAndSubmit() actually signs and broadcasts the transaction
    await myContract
      .send("approve", {
        origin: account.address,
        data: { spender, value },
      })
      .signAndSubmit(signer);
  };

  return { readAllowance, approveSpender };
};

Enter fullscreen mode Exit fullscreen mode

The Helpers(This file is inside the utils folder

)

It ties together address encoding, signers, and data conversions that our ink! client depends on.

Here’s the real code, fully annotated

import { decodeAddress, keccak256AsU8a } from "@polkadot/util-crypto";
import { u8aToHex } from "@polkadot/util";
import { connectInjectedExtension } from "@polkadot-api/pjs-signer";
import type { WalletAccount } from "@talismn/connect-wallets";

/**
 *  truncateText
 * Utility for shortening long strings like wallet addresses for display.
 * Example: truncateText("5GrwvaE...", 6, 4) → "5Grwva...kutQY"
 */
export function truncateText(text: string, start: number, end: number): string {
  if (!text || text.length < 1) return "";
  if (start < 0 || start >= text.length || end >= text.length) {
    throw new Error("Invalid start or end values");
  }
  return `${text.slice(0, start)}...${text.slice(text.length - end)}`;
}

/**
 *  decodeU128
 * Converts a U128 value (which can come as an array of four 64-bit parts)
 * back into a single bigint. This is helpful for decoding contract values
 * returned as arrays by ink! (like balances or totalSupply).
 */
export function decodeU128(words: bigint[] | bigint): bigint {
  if (typeof words === "bigint") return words; // already normalized
  if (!Array.isArray(words)) throw new Error("Unexpected type");

  return words[0] + (words[1] << 64n) + (words[2] << 128n) + (words[3] << 192n);
}

/**
 * convertSS58toHex
 * Converts a standard Substrate/Polkadot SS58 address
 * into a 20-byte Ethereum-compatible hex string.
 *
 * Used when interacting with ink! PSP22 tokens on Polkadot chains
 * because ink! expects Binary (0x...) formatted addresses.
 */
export function convertSS58toHex(address: string): string {
  //  Decode the Polkadot (SS58) address into raw public key bytes
  const pubKey = decodeAddress(address);

  //  Hash it using keccak256 to mimic Ethereum-style address derivation
  const pubKeyDigest = keccak256AsU8a(pubKey);

  //  Take the last 20 bytes (standard Ethereum address size)
  const hexAddress = pubKeyDigest.slice(-20);

  //  Convert to readable hex string
  console.log({ hexAddress, textHexAddress: u8aToHex(hexAddress) });
  return u8aToHex(hexAddress);
}

/**
 *  getPolkadotSigner
 * Connects to the injected wallet extension (e.g., Polkadot.js or Talisman)
 * and retrieves the signer for the currently selected account.
 *
 * The signer is what actually signs transactions on-chain.
 */
export async function getPolkadotSigner(account: WalletAccount) {
  // Connect to the injected extension (defaults to polkadot-js)
  const signers = await connectInjectedExtension(
    account.wallet?.extensionName || "polkadot-js"
  );

  console.log({ signers });

  // Find the signer matching our connected account
  const signer = signers
    .getAccounts()
    .find(({ address }) => address == account.address);

  // Return the signer object (used in .signAndSubmit)
  return signer?.polkadotSigner;
}

Enter fullscreen mode Exit fullscreen mode
  • Let’s say you’re calling:
await inkCtx.transferToken(
  Binary.fromHex(convertSS58toHex(transferRecipient)), // uses helper
  BigInt(transferAmount),
  selectedAccount
);

Enter fullscreen mode Exit fullscreen mode

The convertSS58toHex() makes sure your recipient address is in the correct format for ink! contracts.

The selectedAccount comes with a signer fetched via getPolkadotSigner(), which authenticates the transaction through the connected wallet.

Step 7: Bringing It All Together

Now that the contexts (WalletContext, InkClientContext) and contract interaction functions are ready, it’s time to build the main page that connects everything together.

This page.tsx file acts as the interface between the user and the ink! smart contract, handling token transfers, approvals, minting, burning, and deployment, all powered by our SDK and contexts.

Let’s break down how it works

Here’s the complete UI flow in action:

  • User connects a wallet (handled globally in WalletContext).
  • Token data loads automatically via useEffect.
  • Each section has its own handler (transfer, approve, mint, burn, deploy).
  • Toasts show live transaction feedback.
  • Balance and supply refresh dynamically after each successful transaction.

    page.tsx file uses:

  • WalletContext → manages connected wallet and selected account.

  • InkClientContext → gives access to contract methods (transferToken, approveToken, mintToken, etc.).

  • react-hot-toast → shows status updates for blockchain transactions.

  • useEffect → refetches token info whenever the wallet changes.

Example: Token Transfer Handler

// Function to handle token transfer between two accounts
const handleTransferToken = async () => {
  // Ensure a wallet is connected and the transfer function exists
  if (!selectedAccount || !inkCtx?.transferToken) return;

  // Set the loading state for the transfer button and show a loading toast
  setLoadingStates(prev => ({ ...prev, transfer: true }));
  toast.loading("Preparing transfer...", { id: "transfer" });

  try {
    // Execute the transfer using the ink! client context

    await inkCtx.transferToken(
      //  Recipient Address
      // The recipient can come in two forms:
      //  - If it's already a hex (starts with "0x"), use it directly.
      //  - Otherwise, it's an SS58 address (like from Polkadot.js), so convert it.
      transferRecipient.startsWith("0x")
        ? Binary.fromHex(transferRecipient)
        : Binary.fromHex(convertSS58toHex(transferRecipient)),

      //  Amount
      // The amount entered in the input is converted to a BigInt
      // because blockchain values (like balances and token amounts)
      // must be represented as big integers to prevent rounding errors.
      BigInt(transferAmount || "0"),

      //  Account
      // The selected wallet account — this acts as the "origin"
      // (the sender of the transaction) and determines who signs it.
      selectedAccount
    );

    // If no error, the transfer succeeded
    toast.success("Transfer successful!", { id: "transfer" });

    //  Refresh user data to reflect the updated balance
    await refreshUserData();

  } catch (err) {
    // Catch any failure, invalid address, insufficient funds, RPC error, etc.
    toast.error(
      err instanceof Error ? err.message : "Transfer failed",
      { id: "transfer" }
    );
  } finally {
    // Reset loading state no matter what
    setLoadingStates(prev => ({ ...prev, transfer: false }));
  }
};

Enter fullscreen mode Exit fullscreen mode

This is just a snippet of a function from the page.tsx file with an inline explanation. Complete code can be found here

Conclusion

Everything shared here came directly from what I learned during my journey building and understanding how ink! Smart contracts interact with the frontend.

There are definitely many approaches out there, more abstracted SDKs, different wallet connectors, and even UI frameworks that simplify everything.
But I decided to go through this process step by step to see how things work under the hood, from wallet connections to contract queries and signed transactions.

My goal wasn’t to show the only way, but to share one working approach that I believe helps new developers understand what’s really happening when your frontend talks to the blockchain.

If you’re new to Polkadot or ink!, I hope this helped you gain clarity on how data flows from the UI to the chain and back,
and how .query() and .send() play their unique roles in that communication.

For me personally, moving forward, I’ll be using ready-made templates and tools that speed up the process,
but I’ll always appreciate the foundation this gave me.

If you’d like to explore this full project, you can clone the complete repository and play around with it here:
The frontend: https://github.com/Rampop01/psp_token-frontend/tree/master

The smartcontract: https://github.com/Rampop01/cross-contract-unit-testing

Try changing things, running queries, or sending transactions. It’s one of the best ways to really learn by doing.

If this article helped you understand how to structure your frontend and interact with ink! contracts more confidently, especially as a beginner, then it’s done its job.
Thanks for reading through my first ink! project, and see you on-chain.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.