DEV Community

Christopher Kalule
Christopher Kalule

Posted on

Building a Poetry Auction Smart Contract on Polkadot with ink!

Part 2

Introduction

Welcome back to our Poet Chain X series! In Part 1, we built the complete smart contract logic for our poetry auction platform. Now it's time to bring it to life by deploying to a test network and building a user interface to interact with it.

In this part, we'll cover:

  • Ink!v6, Revive pallet, Account mapping
  • Building and deploying the contract to Paseo testnet
  • Interacting with the contract using Polkadot API (PAPI)
  • Building a Next.js frontend with the ink! SDK

Prerequisites

Before starting, ensure you have:

  • Completed Part 1 (contract code ready)
  • cargo-contract CLI installed
  • A Polkadot.js wallet extension with test accounts
  • Node.js 18+ and pnpm installed
  • Basic familiarity with React/Next.js

Ink!v6, Revive, Account Mapping

What Changed in ink! v6?

ink! v6 introduced a significant architectural change by targeting the pallet-revive instead of the older pallet-contracts. This change was made to achieve compatibility with EVM contracts while maintaining ink!'s Rust-based development experience.

Key Differences:

  1. Addresses: Contracts now use Ethereum-style 20-byte addresses (like 0x6f38a07b...) instead of 32-byte SS58 addresses
  2. File Format: Contract blobs now use .polkavm format instead of .wasm
  3. Account Mapping: Accounts must be explicitly mapped before any contract interaction

What is "Mapping" and Why Does it Matter?

In blockchains using pallet-revive (which enables ink! and Solidity contracts in Substrate chains), accounts come in two flavors:

  1. Substrate (native) accounts — 32-byte identifiers in the standard Polkadot/Substrate format (SS58 addresses like 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY)
  2. Ethereum-style addresses — 20-byte (H160) addresses used in the EVM/contract world (like 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc)

Mapping is the process of linking a native Substrate account to an Ethereum-style address (H160). It doesn't issue a new keypair or change your identity it simply registers the association on-chain via a map_account transaction.

Why is This Needed?

The contract layer (pallet-revive) expects addresses in Ethereum format when performing:

  • Contract calls
  • Contract instantiations
  • Gas accounting
  • Event emission

By mapping your account, you tell the runtime: "Yes, this Substrate account is allowed to interact in the contract world under this corresponding H160 address."

Without mapping, any attempt by a native account to deploy or call a contract will be rejected with DispatchError::AccountUnmapped. This applies to all contract operations, including read-only queries (dry-runs).

Building the Contract

Step 1: Compile the Contract

From your project root directory:

cargo contract build --release
Enter fullscreen mode Exit fullscreen mode

This generates three important files in target/ink/:

  • poet_chain_x.polkavm - The deployable contract blob
  • poet_chain_x.json - Contract metadata with ABI
  • poet_chain_x.contract - Combined metadata and code

Step 2: Verify Build Output

Check the build was successful:

ls -lh target/ink/
Enter fullscreen mode Exit fullscreen mode

You should see your .polkavm file (the compiled contract) and .json metadata file.

Deploying to Paseo Testnet

Step 3: Get Test Tokens

Before deploying, you need PAS tokens:

  1. Visit the Paseo Faucet
  2. Select "Paseo Asset Hub" network
  3. Enter your wallet address
  4. Request tokens

Step 4: Map Your Account

Before deploying or interacting with contracts, you must map your account. You can do this using the Polkadot.js Apps interface:

  1. Connect to Paseo Asset Hub
  2. Navigate to Developer → Extrinsics
  3. Select your account
  4. Choose revive → mapAccount() Screenshot of polkadot js
  5. Submit the transaction

Step 5: Deploy Using cargo-contract

cargo contract instantiate \
  --constructor new \
  --args "Roses are red, violets are blue" 100 \
  --suri //Alice \
  --url wss://testnet-passet-hub.polkadot.io \
  --execute
Enter fullscreen mode Exit fullscreen mode

Breaking down the command:

  • instantiate: Deploy and initialize the contract
  • --constructor new: Call the new constructor
  • --args: Pass poem text and duration (100 blocks)
  • --suri //Alice: Use Alice's test account
  • --url: Connect to Paseo testnet
  • --execute: Actually perform the deployment (remove for dry-run)

Output:

  Event Instantiated
    deployer: 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY
    contract: 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc

Code hash 0x7cfc118b19d5e0a224ae0932a25986c439cec72dfc096511f74e549a4bcc03ff
Enter fullscreen mode Exit fullscreen mode

Save your contract address! You'll need it for all future interactions.

Interacting with the Contract via CLI

Let's test our deployed contract using the cargo contract CLI.

Reading Contract State

Query the stored poem:

cargo contract call \
  --contract 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc \
  --message get_poem \
  --suri //Alice \
  --url wss://testnet-passet-hub.polkadot.io
Enter fullscreen mode Exit fullscreen mode

Check auction info (current block, end block, active status):

cargo contract call \
  --contract 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc \
  --message get_auction_info \
  --suri //Alice \
  --url wss://testnet-passet-hub.polkadot.io
Enter fullscreen mode Exit fullscreen mode

Placing a Bid

cargo contract call \
  --contract 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc \
  --message bid \
  --suri //Bob \
  --value 1000000000000 \
  --url wss://testnet-passet-hub.polkadot.io \
  --execute \
  --skip-confirm
Enter fullscreen mode Exit fullscreen mode

Important: The --value is in Planck (smallest unit). The above sends 1 PAS token.

Ending the Auction

After the auction duration has passed:

cargo contract call \
  --contract 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc \
  --message end_auction \
  --suri //Alice \
  --url wss://testnet-passet-hub.polkadot.io \
  --execute
Enter fullscreen mode Exit fullscreen mode

Using the Bash Test Script

The repository includes test_auction.sh for automated testing. Update the contract address:

#!/bin/bash
CONTRACT="0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc"  # Your deployed address

# The script then runs through the complete auction flow
Enter fullscreen mode Exit fullscreen mode

Run it:

chmod +x test_auction.sh
./test_auction.sh
Enter fullscreen mode Exit fullscreen mode

Building the Frontend

Now let's create a web interface using Next.js and the Polkadot API (PAPI) with the ink! SDK.

Step 1: Setup Next.js Project

npx create-next-app@latest poet-chain-x --typescript
cd poet-chain-x
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Dependencies

pnpm install @polkadot-api/sdk-ink polkadot-api @polkadot/extension-dapp
pnpm install @polkadot/api @polkadot/util @polkadot/util-crypto
pnpm install lucide-react @radix-ui/react-tabs @radix-ui/react-select
Enter fullscreen mode Exit fullscreen mode

Step 3: Generate Type Definitions

This is the magic that gives you full TypeScript support:

# Generate chain types
pnpm papi add -w wss://testnet-passet-hub.polkadot.io passet

# Generate contract types from metadata
pnpm papi ink add ./contracts_p/poet_chain_x.json
Enter fullscreen mode Exit fullscreen mode

This creates .papi/descriptors/dist.ts with typed contract interfaces.

Understanding the ink! SDK

The ink! SDK provides a high-level API for contract interaction:

import { createInkSdk } from "@polkadot-api/sdk-ink"
import { createClient } from "polkadot-api"
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"
import { getWsProvider } from "polkadot-api/ws-provider"
import { contracts } from '.papi/descriptors'

// Create client
const client = createClient(
  withPolkadotSdkCompat(
    getWsProvider("wss://testnet-passet-hub.polkadot.io")
  )
)

// Create ink SDK
const inkSdk = createInkSdk(client)
Enter fullscreen mode Exit fullscreen mode

Working with Contract Addresses

Checking if Account is Mapped

Before any interaction:

const isMapped = await inkSdk.addressIsMapped(accountAddress)
if (!isMapped) {
  console.log("Account needs to be mapped first!")
  // Show UI prompt to map account
}
Enter fullscreen mode Exit fullscreen mode

Getting Contract Instance

const contractAddress = "0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc"
const poetContract = inkSdk.getContract(contracts.poet_chain_x, contractAddress)

// Verify contract compatibility
if (!(await poetContract.isCompatible())) {
  throw new Error("Contract code hash has changed")
}
Enter fullscreen mode Exit fullscreen mode

Querying Contract State

Queries are read-only operations that don't cost gas:

// Get the poem
const poemResult = await poetContract.query("get_poem", {
  origin: accountAddress,
})

if (poemResult.success) {
  console.log("Poem:", poemResult.value.response)
} else {
  console.error("Query failed:", poemResult.value)
}

// Get auction info
const infoResult = await poetContract.query("get_auction_info", {
  origin: accountAddress,
})

if (infoResult.success) {
  const [currentBlock, endBlock, isActive] = infoResult.value.response
  console.log(`Blocks remaining: ${endBlock - currentBlock}`)
  console.log(`Active: ${isActive}`)
}

// Get winner
const winnerResult = await poetContract.query("get_winner", {
  origin: accountAddress,
})

if (winnerResult.success) {
  const [bidderOption, amount] = winnerResult.value.response
  if (bidderOption) {
    const bidderAddress = bidderOption.asHex()
    console.log(`Highest bidder: ${bidderAddress}`)
    console.log(`Amount: ${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

Sending Transactions

Transactions modify state and require signing:

import {
  getInjectedExtensions,
  connectInjectedExtension,
} from "polkadot-api/pjs-signer"

// Connect to wallet
const extensions = getInjectedExtensions()
const selectedExtension = await connectInjectedExtension(extensions[0])
const accounts = selectedExtension.getAccounts()
const polkadotSigner = accounts[0].polkadotSigner

// Place a bid
const bidAmount = 1.5 // PAS tokens
const bidInPlanck = BigInt(Math.floor(bidAmount * 1_000_000_000_000))

const bidResult = await poetContract
  .send("bid", {
    origin: accountAddress,
    value: bidInPlanck,
  })
  .signAndSubmit(polkadotSigner)

if (bidResult.ok) {
  console.log("Bid placed in block:", bidResult.block)

  // Filter contract events
  const events = poetContract.filterEvents(bidResult.events)
  console.log("Contract events:", events)
} else {
  console.error("Transaction failed:", bidResult.dispatchError)
}
Enter fullscreen mode Exit fullscreen mode

Deploying from Frontend

You can deploy contracts directly from your app:

import { Binary } from "polkadot-api"

// Read contract file uploaded by user
const contractFile: File = ... // from file input
const arrayBuffer = await contractFile.arrayBuffer()
const codeBlob = new Uint8Array(arrayBuffer)
const code = Binary.fromBytes(codeBlob)

// Get deployer
const deployer = inkSdk.getDeployer(contracts.poet_chain_x, code)

// Estimate deployment address (optional)
const estimatedAddress = await deployer.estimateAddress("new", {
  origin: accountAddress,
  data: {
    poem: poemText,
    duration: durationBlocks,
  },
})

console.log("Contract will be deployed at:", estimatedAddress)

// Dry run first
const dryRunResult = await deployer.dryRun("new", {
  origin: accountAddress,
  data: {
    poem: poemText,
    duration: durationBlocks,
  },
})

if (!dryRunResult.success) {
  console.error("Deployment dry-run failed")
  return
}

// Actual deployment
const deployResult = await dryRunResult.value
  .deploy()
  .signAndSubmit(polkadotSigner)

// Get deployed address
const deploymentData = inkSdk.readDeploymentEvents(
  contracts.poet_chain_x,
  deployResult.events
)

console.log("Deployed at:", deploymentData[0].address)
console.log("Events:", deploymentData[0].contractEvents)
Enter fullscreen mode Exit fullscreen mode

Complete Frontend Component Example

Here's the core auction manager component:

"use client"

import { useState, useEffect } from "react"
import { createInkSdk } from "@polkadot-api/sdk-ink"
import { createClient } from "polkadot-api"
import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"
import { getWsProvider } from "polkadot-api/ws-provider"
import { contracts } from '@/.papi/descriptors'

export function AuctionManager({ account }: { account: string }) {
  const [contractAddress, setContractAddress] = useState("")
  const [poemText, setPoemText] = useState("")
  const [currentBid, setCurrentBid] = useState("0")
  const [highestBidder, setHighestBidder] = useState("")
  const [isLoading, setIsLoading] = useState(false)

  // Format PAS tokens from Planck
  const formatPAS = (planck: string) => {
    const pas = Number(planck) / 1_000_000_000_000
    return pas.toFixed(4)
  }

  // Load auction data
  const loadAuction = async () => {
    if (!contractAddress) return

    setIsLoading(true)
    try {
      const client = createClient(
        withPolkadotSdkCompat(
          getWsProvider("wss://testnet-passet-hub.polkadot.io")
        )
      )
      const inkSdk = createInkSdk(client)

      // Check if account is mapped
      const isMapped = await inkSdk.addressIsMapped(account)
      if (!isMapped) {
        alert("Please map your account first using the Revive pallet")
        return
      }

      const contract = inkSdk.getContract(contracts.poet_chain_x, contractAddress)

      // Query poem
      const poemResult = await contract.query("get_poem", { origin: account })
      if (poemResult.success) {
        setPoemText(poemResult.value.response)
      }

      // Query winner
      const winnerResult = await contract.query("get_winner", { origin: account })
      if (winnerResult.success) {
        const [bidderOption, amount] = winnerResult.value.response
        if (bidderOption) {
          setHighestBidder(bidderOption.asHex())
        }
        setCurrentBid(amount.toString())
      }

      client.destroy()
    } catch (error) {
      console.error("Failed to load auction:", error)
      alert("Error loading auction")
    } finally {
      setIsLoading(false)
    }
  }

  // Place bid
  const placeBid = async (bidAmount: string) => {
    // Implementation shown above
  }

  return (
    <div className="space-y-4">
      <input
        type="text"
        placeholder="Contract Address"
        value={contractAddress}
        onChange={(e) => setContractAddress(e.target.value)}
        className="w-full px-4 py-2 border rounded"
      />
      <button
        onClick={loadAuction}
        disabled={isLoading}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        {isLoading ? "Loading..." : "Load Auction"}
      </button>

      {poemText && (
        <div className="p-4 bg-gray-100 rounded">
          <h3 className="font-bold mb-2">Poem:</h3>
          <p className="whitespace-pre-line">{poemText}</p>
        </div>
      )}

      {currentBid !== "0" && (
        <div className="p-4 border rounded">
          <p>Current Bid: {formatPAS(currentBid)} PAS</p>
          {highestBidder && (
            <p className="text-sm text-gray-600 font-mono">
              Bidder: {highestBidder}
            </p>
          )}
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Important Account Mapping Workflow

Here's a complete flow for handling account mapping in your app:

async function ensureAccountMapped(inkSdk: InkSdk, address: string) {
  // Check if already mapped
  const isMapped = await inkSdk.addressIsMapped(address)

  if (!isMapped) {
    console.log("Account not mapped. Requesting mapping transaction...")

    // Show UI to user
    alert(`Your account needs to be mapped before interacting with contracts. 
           Please submit a Revive.mapAccount() transaction.`)

    // You can also programmatically submit the mapping transaction
    // using typedApi.tx.Revive.map_account()

    return false
  }

  return true
}

// Use before any contract interaction
const canInteract = await ensureAccountMapped(inkSdk, userAddress)
if (!canInteract) {
  return // Show error or mapping instructions
}
Enter fullscreen mode Exit fullscreen mode

Troubleshooting Common Issues

1. "Account not mapped" Error

Problem: Trying to interact with contract before mapping account.

Solution:

const isMapped = await inkSdk.addressIsMapped(account)
if (!isMapped) {
  // Submit Revive.map_account() transaction
}
Enter fullscreen mode Exit fullscreen mode

2. "Bid too low" Error

Problem: Bid amount not exceeding current highest bid.

Solution: Always fetch current bid first and ensure new bid is strictly greater:

const minimumBid = (currentBid / 1_000_000_000_000) + 0.0001 // Add minimum increment
Enter fullscreen mode Exit fullscreen mode

3. Contract Address Format

Problem: Using SS58 address instead of Ethereum-style address.

Solution: ink! v6 contracts use 20-byte hex addresses:

  • 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY
  • 0x6f38a07b338aed6b7146df28ea2a4f8d2c420afc

4. Transaction Fails Silently

Problem: Not checking transaction result properly.

Solution:

const result = await contract.send(...).signAndSubmit(signer)

if (result.ok) {
  console.log("Success!")
} else {
  console.error("Failed:", result.dispatchError)
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Always Dry-Run First

Before submitting transactions, dry-run to estimate gas and check for errors:

const dryRun = await contract.query("bid", {
  origin: account,
  value: bidAmount,
})

if (dryRun.success) {
  // Now send actual transaction
  const tx = await contract.send("bid", { ... })
}
Enter fullscreen mode Exit fullscreen mode

3. Cache Contract Metadata

Don't regenerate contract types on every build. Commit .papi/descriptors to version control.

4. Use Proper Error Handling

try {
  const result = await contract.query(...)
  if (result.success) {
    // Handle success
  } else {
    // Handle contract error
    console.error("Contract error:", result.value)
  }
} catch (error) {
  // Handle network/SDK error
  console.error("SDK error:", error)
}
Enter fullscreen mode Exit fullscreen mode

Summary

In Part 2, we've covered:

✅ Ink! v6 and the Revive pallet

✅ Building and deploying contracts to Paseo testnet

✅ Critical importance of account mapping

✅ Interacting with contracts via cargo-contract CLI

✅ Building a full-featured frontend with PAPI ink! SDK

✅ Best practices for production deployment

Resources

What's Next?

If you enjoyed following along and want to see this project fully realized, here’s a little challenge for you: If this article gets 1,000 likes, we’ll actually build the full production ready Poet Chain X platform. That means a live auction site, interactive frontend, and everything you’ve learned here is ready for the real world.

And if you’ve tried building your own version or made tweaks to the contract or UI, drop a comment below and share your progress. I’d love to see what you’ve built!

Consider this your invitation to help turn code into reality!

Congratulations! You've now built a complete blockchain-powered poetry auction platform. You've learned contract development, deployment, and frontend integration on Polkadot. These skills transfer to any ink! smart contract project.

Have questions? The complete source code for both the contract and frontend is available in the GitHub repository.

Top comments (0)