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:
- A poet creates an auction by deploying a contract instance with their poem and auction duration
- Buyers place bids by sending tokens to the contract
- The contract automatically refunds previous bidders when outbid
- 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
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" }
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?
}
Design Notes:
- We store a hash (
poem_id
) for poem verification and emit it in events -
highest_bidder
is anOption
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],
}
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>;
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
}
}
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
}
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(())
}
Important Design Decisions:
-
Automatic Refunds: When a new highest bid comes in, we immediately refund the previous bidder. This prevents funds from being locked in the contract.
-
Strict Inequality: We use
value <= self.highest_bid
to reject equal bids. This means the first person to bid a certain amount has priority. -
Block-Based Timing: We use block numbers instead of timestamps because they’re more reliable on-chain.
-
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(())
}
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)
}
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);
}
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));
}
Running Tests
Execute your tests with:
cargo test
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
-
ink! uses familiar Rust syntax but with special attributes like
#[ink(storage)]
and#[ink(message)]
- Payable functions enable contracts to receive tokens
- Events are crucial for off-chain systems to track contract activity
- Test-driven development catches bugs early in smart contract development
- 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.