Building a Poetry Auction Smart Contract on Polkadot with ink!




Part 1



Introduction

Imagine auctioning your original poetry on the blockchain, where the highest bidder becomes the rightful owner of your creative work. In this two-part series, we’ll build Poet Chain X, a smart contract that enables poets to auction their work on the Polkadot blockchain using ink!, the Rust-based smart contract language.

In Part 1, we’ll cover the foundational concepts, build the core auction logic and testing. Part 2 will focus on deployment and interacting with the contract on a live test network.



What You’ll Learn

  • Setting up an ink! smart contract project
  • Understanding the contract architecture pattern
  • Implementing payable functions for handling token transfers
  • Managing auction state and business logic
  • Writing comprehensive unit tests



Prerequisites

Before we begin, ensure you have:



Project Overview

Poet Chain X allows users to auction their original poetry on the blockchain. Here’s how it works:

  1. A poet creates an auction by deploying a contract instance with their poem and auction duration
  2. Buyers place bids by sending tokens to the contract
  3. The contract automatically refunds previous bidders when outbid
  4. After the auction ends, the highest bidder wins, and funds are transferred to the poet



Key Design Decision: One Contract Per Auction

We’re using a “one contract instance per auction” pattern. When Alice wants to auction a poem, she instantiates the contract. If Bob also wants to auction, he instantiates another copy from the same Wasm code. Each instance is independent with its own contract address.



Setting Up the Project

Let’s start by creating our project structure:

cargo contract new poet_chain_x
cd poet_chain_x
Enter fullscreen mode

Exit fullscreen mode

Here’s our Cargo.toml configuration:

[package]
name = "poet_chain_x"
version = "0.1.0"
edition = "2024"

[dependencies]
ink = { git = "https://github.com/use-ink/ink", 
        tag = "v6.0.0-alpha.4", 
        version = "6.0.0-alpha.4", 
        default-features = false, 
        features = ["unstable-hostfn"] }

[dev-dependencies]
ink_e2e = { git = "https://github.com/use-ink/ink", 
            tag = "v6.0.0-alpha.4", 
            version = "6.0.0-alpha.4" }
Enter fullscreen mode

Exit fullscreen mode



Understanding the Contract Architecture



The Storage Structure

Our contract needs to track several pieces of information:

#[ink(storage)]
pub struct PoetChainX {
    poem_id: [u8; 32],           // Hash of the poem for verification
    seller: Address,              // The poet who created the auction
    poem: String,                 // The actual poem text
    end_block: BlockNumber,       // When the auction ends
    highest_bid: U256,            // Current highest bid amount
    highest_bidder: Option<Address>, // Current winner (None if no bids)
    active: bool,                 // Is the auction still running?
}
Enter fullscreen mode

Exit fullscreen mode

Design Notes:

  • We store a hash (poem_id) for poem verification and emit it in events
  • highest_bidder is an Option

    because auctions start with no bidders
  • active flag prevents operations after auction ends (gas optimization)
  • U256 Contract Balance type ink! v6



Events for Transparency

Events allow off-chain systems (UIs, indexers) to track auction activity:

#[ink(event)]
pub struct AuctionCreated {
    #[ink(topic)]
    seller: Address,
    #[ink(topic)]
    poem_id: [u8; 32],
}

#[ink(event)]
pub struct BidPlaced {
    #[ink(topic)]
    bidder: Address,
    amount: U256,
    poem_id: [u8; 32],
}

#[ink(event)]
pub struct BidRefunded {
    #[ink(topic)]
    previous_bidder: Address,
    amount: U256,
    poem_id: [u8; 32],
}

#[ink(event)]
pub struct AuctionEnded {
    #[ink(topic)]
    winner: Option<Address>,
    amount: U256,
    poem_id: [u8; 32],
}
Enter fullscreen mode

Exit fullscreen mode

The #[ink(topic)] attribute makes fields indexable, allowing efficient filtering of events.



Error Handling

We define clear error types for all failure scenarios:

#[derive(Debug, PartialEq, Eq)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
pub enum Error {
    AuctionNotActive,      // Auction has ended
    AuctionExpired,        // Bid placed after end_block
    BidTooLow,            // Bid not higher than current highest
    TransferFailed,        // Token transfer didn't succeed
    AuctionAlreadyEnded,   // Trying to end auction twice
    AuctionStillRunning,   // Trying to end before end_block
    SomethingWentWrong,    // Generic fallback
    UnAuthorized          // Only seller can end auction
}

pub type Result<T> = core::result::Result<T, Error>;
Enter fullscreen mode

Exit fullscreen mode



Building the Constructor

The constructor initializes a new auction:

#[ink(constructor)]
pub fn new(poem: String, duration: BlockNumber) -> Self {
    // Get current blockchain block number
    let current_block = Self::env().block_number();

    // Hash the poem for verification
    let res_hash = Self::hash_poem(poem.as_bytes());

    // Calculate when auction ends (with overflow protection)
    let end_block = current_block
        .checked_add(duration)
        .unwrap_or(current_block);

    // Emit creation event
    Self::env().emit_event(AuctionCreated {
        seller: Self::env().caller(),
        poem_id: res_hash,
    });

    // Initialize storage
    Self {
        poem_id: res_hash,
        seller: Self::env().caller(),  // Caller becomes seller
        poem,
        end_block,
        highest_bid: U256::zero(),     // Start with no bids
        highest_bidder: None,
        active: true,                   // Auction is live
    }
}
Enter fullscreen mode

Exit fullscreen mode

Key Points:

  • Self::env().caller() identifies who deployed the contract
  • checked_add prevents integer overflow attacks
  • The poem is hashed using Keccak256 for efficient verification



Helper Function: Hashing the Poem

fn hash_poem(poem: &[u8]) -> [u8; 32] {
    let mut output = <Keccak256 as HashOutput>::Type::default();
    ink::env::hash_bytes::<Keccak256>(poem, &mut output);
    output
}
Enter fullscreen mode

Exit fullscreen mode



Implementing the Bidding Logic

The bid function is the heart of our auction. It’s marked as payable, meaning callers must send tokens:

#[ink(message, payable)]
pub fn bid(&mut self) -> Result<()> {
    // STEP 1: Check if auction is still accepting bids
    if !self.active {
        return Err(Error::AuctionNotActive);
    }

    // STEP 2: Check if auction time hasn't expired
    let current_block = Self::env().block_number();
    if current_block > self.end_block {
        return Err(Error::AuctionExpired);
    }

    // STEP 3: Get bidder info and bid amount
    let bidder = Self::env().caller();
    let value = Self::env().transferred_value();  // How much was sent

    // STEP 4: Validate bid is higher than current highest
    if value <= self.highest_bid {
        return Err(Error::BidTooLow);  // Equal bids rejected!
    }

    // STEP 5: Refund the previous highest bidder
    if let Some(prev_bidder) = self.highest_bidder {
        if self.highest_bid > U256::zero() {
            if !Self::env().transfer(prev_bidder, self.highest_bid).is_ok() {
                return Err(Error::TransferFailed);
            }

            Self::env().emit_event(BidRefunded {
                previous_bidder: prev_bidder,
                amount: self.highest_bid,
            });
        }
    }

    // STEP 6: Update contract state with new highest bid
    self.highest_bid = value;
    self.highest_bidder = Some(bidder);

    // STEP 7: Emit event about the new bid
    Self::env().emit_event(BidPlaced {
        bidder,
        amount: value,
    });

    Ok(())
}
Enter fullscreen mode

Exit fullscreen mode

Important Design Decisions:

  1. Automatic Refunds: When a new highest bid comes in, we immediately refund the previous bidder. This prevents funds from being locked in the contract.

  2. Strict Inequality: We use value <= self.highest_bid to reject equal bids. This means the first person to bid a certain amount has priority.

  3. Block-Based Timing: We use block numbers instead of timestamps because they’re more reliable on-chain.

  4. Transfer Error Handling: We explicitly check if transfers succeed and return an error if they fail.



Ending the Auction

Only the seller can end the auction, and only after the end block:

#[ink(message, payable)]
pub fn end_auction(&mut self) -> Result<()> {
    // Check if already ended
    if !self.active {
        return Err(Error::AuctionAlreadyEnded);
    }

    // Only seller can end
    if self.seller != Self::env().caller() {
        return Err(Error::UnAuthorized);
    }

    // Check if auction time has passed
    let current_block = Self::env().block_number();
    if current_block <= self.end_block {
        return Err(Error::AuctionStillRunning);
    }

    // Mark as inactive (prevents re-entrance)
    self.active = false;

    // Transfer funds to seller if there was a winner
    if let Some(winner) = self.highest_bidder {
        if !Self::env().transfer(self.seller, self.highest_bid).is_ok() {
            return Err(Error::TransferFailed);
        }
        Self::env().emit_event(AuctionEnded { 
            winner: Some(winner), 
            amount: self.highest_bid
        });
    } else {
        // No bids placed
        Self::env().emit_event(AuctionEnded { 
            winner: None, 
            amount: self.highest_bid
        });
    }

    Ok(())
}
Enter fullscreen mode

Exit fullscreen mode



Query Functions

These functions let users check the auction state without modifying it:

#[ink(message)]
pub fn get_poem(&self) -> String {
    self.poem.clone()
}

#[ink(message)]
pub fn get_winner(&self) -> (Option<Address>, U256) {
    (self.highest_bidder, self.highest_bid)
}

#[ink(message)]
pub fn get_auction_info(&self) -> (BlockNumber, BlockNumber, bool) {
    (Self::env().block_number(), self.end_block, self.active)
}
Enter fullscreen mode

Exit fullscreen mode



Testing the Contract

ink! provides a powerful testing framework. Here’s a complete happy path test:

#[ink::test]
fn happy_path_auction_flow() {
    let poem = "Roses are red, violets are blue".to_owned();
    let accounts = test::default_accounts();

    // Alice creates the auction
    test::set_caller(accounts.alice);
    let mut contract = PoetChainX::new(poem.clone(), 100);

    // Verify initial state
    assert_eq!(contract.get_poem(), poem);
    assert!(contract.active);

    // Bob places a bid
    test::set_caller(accounts.bob);
    test::set_value_transferred(U256::from(1000));
    assert!(contract.bid().is_ok());

    // Check Bob is winning
    let (winner, amount) = contract.get_winner();
    assert_eq!(winner, Some(accounts.bob));
    assert_eq!(amount, U256::from(1000));

    // Advance past end block
    test::set_block_number::<ink::env::DefaultEnvironment>(150);

    // Alice ends the auction
    test::set_caller(accounts.alice);
    contract.end_auction().expect("Expected to end contract");
    assert!(!contract.active);
}
Enter fullscreen mode

Exit fullscreen mode



Testing Unhappy Paths

We should also test failure scenarios:

#[ink::test]
fn bid_too_low() {
    let poem = "Test poem".to_owned();
    let mut contract = PoetChainX::new(poem, 100);
    let accounts = test::default_accounts();

    // Bob bids 1000
    test::set_caller(accounts.bob);
    test::set_value_transferred(U256::from(1000));
    assert!(contract.bid().is_ok());

    // Charlie tries to bid lower
    test::set_caller(accounts.charlie);
    test::set_value_transferred(U256::from(500));
    let result = contract.bid();

    assert_eq!(result, Err(Error::BidTooLow));
}

#[ink::test]
fn bid_after_expiry() {
    let poem = "Test poem".to_owned();
    let mut contract = PoetChainX::new(poem, 100);

    // Jump to block 150 (past end_block of 100)
    test::set_block_number::<ink::env::DefaultEnvironment>(150);

    let accounts = test::default_accounts();
    test::set_caller(accounts.bob);
    test::set_value_transferred(U256::from(1000));

    let result = contract.bid();
    assert_eq!(result, Err(Error::AuctionExpired));
}
Enter fullscreen mode

Exit fullscreen mode



Running Tests

Execute your tests with:

cargo test
Enter fullscreen mode

Exit fullscreen mode

You should see output indicating all tests passed.



Summary

In Part 1, we’ve built the complete logic for Poet Chain X:

✅ Contract storage structure with efficient state management

✅ Payable bid function with automatic refunds

✅ Authorisation-protected auction ending

✅ Comprehensive event emission for tracking

✅ Query functions for checking auction state

✅ Full test coverage for happy and unhappy paths



What’s Next?

In Part 2, we’ll cover:

  • Building and deploying the contract to a test network
  • Interacting with the deployed contract using Polkadot API (PAPI)



Key Takeaways

  1. ink! uses familiar Rust syntax but with special attributes like #[ink(storage)] and #[ink(message)]
  2. Payable functions enable contracts to receive tokens
  3. Events are crucial for off-chain systems to track contract activity
  4. Test-driven development catches bugs early in smart contract development
  5. The one-contract-per-auction pattern keeps each auction isolated and secure



Resources


Ready to deploy and test your contract? Continue to Part 2, where we’ll bring Poet Chain X to life on a real Polkadot testnet blockchain network!

Have questions or found issues? The complete source code is available in the GitHub repository.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *