WikiGetting hands onBuilding a Smart Contract in TypeScript

A Giftcard HashMap based

We implemented a gift card contract that allows users to create a transaction to lock assets into the smart contract, which any user can redeem.

Redeeming a gift card means burning it and transferring the assets to the redeemer.

A proper way to implement a gift card is via NFT, but here we opted for using a HashMap. The NFT implementation will be in the next chapter.

Why Use HashMaps Instead of NFTs?

  1. Simplicity: Avoids NFT standard complexity for educational purposes
  2. Direct Ownership Tracking: Uses simple owner-value mappings
  3. Minimal Dependencies: No external libraries required
  4. Focus on Core Concepts: Demonstrates basic value locking/transfers

Implementation

#![cfg_attr(not(feature = "std"), no_std, no_main)]
#![allow(unexpected_cfgs)] // Allows for contract-specific configurations
 
/// A simple gift card contract using HashMap-like storage
#[ink::contract]
mod giftcard {
    use ink::prelude::string::String;
    use ink::storage::Mapping;
 
    //----------------------------------
    // Contract Storage Definition
    //----------------------------------
    /// The contract's storage structure
    #[ink(storage)]
    pub struct Giftcard {
        /// Maps gift card IDs to their owners (AccountId)
        owners: Mapping<u64, AccountId>,
        /// Maps gift card IDs to their stored value (Balance)
        values: Mapping<u64, Balance>,
        /// Maps gift card IDs to their human-readable names
        names: Mapping<u64, String>,
        /// Auto-incrementing ID counter for new gift cards
        next_id: u64,
    }
 
    //----------------------------------
    // Initialization Implementation
    //----------------------------------
    /// Default implementation for contract initialization
    impl Default for Giftcard {
        fn default() -> Self {
            Self {
                owners: Mapping::new(),
                values: Mapping::new(),
                names: Mapping::new(),
                next_id: 0,
            }
        }
    }
 
    //----------------------------------
    // Error Handling
    //----------------------------------
    /// Custom error types for the gift card contract
    #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    #[repr(u8)]
    pub enum Error {
        NotOwner = 0,       // Caller doesn't own the gift card
        InvalidValue = 1,   // Transferred value is invalid
        TransferFailed = 2, // Value transfer failed
    }
 
    //----------------------------------
    // Core Contract Logic
    //----------------------------------
    impl Giftcard {
        /// Constructor initializes a new gift card contract
        #[ink(constructor)]
        pub fn new() -> Self {
            Self::default()
        }
 
        /// Create a new gift card by locking funds
        #[ink(message, payable)]
        pub fn create_giftcard(&mut self, name: String) -> Result<u64, Error> {
            // Verify transferred value is greater than 0
            let transferred = self.env().transferred_value();
            if transferred == 0 {
                return Err(Error::InvalidValue);
            }
 
            // Generate new ID and get caller
            let id = self.next_id;
            let caller = self.env().caller();
 
            // Store gift card details
            self.owners.insert(id, &caller);
            self.values.insert(id, &transferred);
            self.names.insert(id, &name);
 
            // Safely increment ID (prevents overflow)
            self.next_id = self.next_id.saturating_add(1);
            
            Ok(id) // Return new gift card ID
        }
 
        /// Redeem a gift card and claim its value
        #[ink(message)]
        pub fn redeem_giftcard(&mut self, id: u64) -> Result<(), Error> {
            let caller = self.env().caller();
 
            // Verify ownership and existence
            let owner = self.owners.get(id).ok_or(Error::InvalidValue)?;
            if owner != caller {
                return Err(Error::NotOwner);
            }
 
            // Retrieve stored value
            let value = self.values.get(id).ok_or(Error::InvalidValue)?;
 
            // Clean up storage
            self.owners.remove(id);
            self.values.remove(id);
            self.names.remove(id);
 
            // Transfer value to caller
            self.env()
                .transfer(caller, value)
                .map_err(|_| Error::TransferFailed)
        }
 
        /// Retrieve gift card details
        #[ink(message)]
        pub fn get_giftcard(&self, id: u64) -> Option<(AccountId, Balance, String)> {
            Some((
                self.owners.get(id)?,
                self.values.get(id)?,
                self.names.get(id)?,
            ))
        }
    }
 
    // ... let's skip the tests for now (it's implemented below) ...
}

Key components explained

1. Storage Structure

#[ink(storage)]
pub struct Giftcard {
    owners: Mapping<u64, AccountId>,
    values: Mapping<u64, Balance>,
    names: Mapping<u64, String>,
    next_id: u64,
}
  • owners: Tracks ownership using simple ID → Account mapping
  • values: Stores locked balance for each gift card
  • names: Human-readable identifier for each card
  • next_id: Simple counter for unique IDs

2. Gift Card Creation

#[ink(message, payable)]
pub fn create_giftcard(&mut self, name: String) -> Result<u64, Error> {
    // Value validation
    let transferred = self.env().transferred_value();
    if transferred == 0 {
        return Err(Error::InvalidValue);
    }
    
    // Store details
    let id = self.next_id;
    self.owners.insert(id, &caller);
    self.values.insert(id, &transferred);
    self.names.insert(id, &name);
    
    // Increment ID safely
    self.next_id = self.next_id.saturating_add(1);
    Ok(id)
}
  • Requires attached value (> 0)
  • Uses auto-incrementing IDs
  • Stores all components separately

3. Gift Card Redemption

#[ink(message)]
pub fn redeem_giftcard(&mut self, id: u64) -> Result<(), Error> {
    // Ownership check
    let owner = self.owners.get(id).ok_or(Error::InvalidValue)?;
    if owner != caller {
        return Err(Error::NotOwner);
    }
    
    // Cleanup storage
    self.owners.remove(id);
    self.values.remove(id);
    self.names.remove(id);
    
    // Value transfer
    self.env().transfer(caller, value)
}
  • Ensures caller owns the gift card
  • Removes all stored data
  • Transfers locked value

Unit Tests

 
    #[cfg(test)]
    mod tests {
        use super::*;
        use ink::env::test;
 
        /// Helper function to set up testing environment
        fn setup_contract() -> Giftcard {
            Giftcard::new()
        }
 
        #[ink::test]
        fn create_giftcard_works() {
            // Arrange
            let mut contract = setup_contract();
            let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
            let value = 1000;
 
            // Act
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
            test::set_value_transferred::<ink::env::DefaultEnvironment>(value);
            let result = contract.create_giftcard("Birthday".to_string());
 
            // Assert
            assert_eq!(result, Ok(0));
            assert_eq!(contract.next_id, 1);
 
            let (owner, stored_value, name) = contract.get_giftcard(0).unwrap();
            assert_eq!(owner, accounts.alice);
            assert_eq!(stored_value, value);
            assert_eq!(name, "Birthday");
        }
 
        #[ink::test]
        fn redeem_giftcard_works() {
            // Arrange
            let mut contract = setup_contract();
            let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
            let value = 1000;
 
            // Create giftcard first
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
            test::set_value_transferred::<ink::env::DefaultEnvironment>(value);
            let id = contract.create_giftcard("Christmas".to_string()).unwrap();
 
            // Get initial balance
            let initial_balance =
                test::get_account_balance::<ink::env::DefaultEnvironment>(accounts.alice).unwrap();
 
            // Act
            let redeem_result = contract.redeem_giftcard(id);
 
            // Assert
            assert_eq!(redeem_result, Ok(()));
 
            // Verify balance increased
            let final_balance =
                test::get_account_balance::<ink::env::DefaultEnvironment>(accounts.alice).unwrap();
            assert_eq!(final_balance, initial_balance + value);
 
            // Verify storage cleanup
            assert_eq!(contract.get_giftcard(id), None);
        }
 
        #[ink::test]
        fn non_owner_cannot_redeem() {
            // Arrange
            let mut contract = setup_contract();
            let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
            let value = 500;
 
            // Alice creates giftcard
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
            test::set_value_transferred::<ink::env::DefaultEnvironment>(value);
            let id = contract.create_giftcard("Anniversary".to_string()).unwrap();
 
            // Bob tries to redeem
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
            let result = contract.redeem_giftcard(id);
 
            // Assert
            assert_eq!(result, Err(Error::NotOwner));
        }
 
        #[ink::test]
        fn redeem_invalid_id_fails() {
            // Arrange
            let mut contract = setup_contract();
            let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
 
            // Act (try to redeem non-existent ID)
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
            let result = contract.redeem_giftcard(999);
 
            // Assert
            assert_eq!(result, Err(Error::InvalidValue));
        }
 
        #[ink::test]
        fn create_with_zero_value_fails() {
            // Arrange
            let mut contract = setup_contract();
            let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
 
            // Act
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
            test::set_value_transferred::<ink::env::DefaultEnvironment>(0);
            let result = contract.create_giftcard("Empty".to_string());
 
            // Assert
            assert_eq!(result, Err(Error::InvalidValue));
        }
 
        #[ink::test]
        fn get_giftcard_returns_correct_data() {
            // Arrange
            let mut contract = setup_contract();
            let accounts = test::default_accounts::<ink::env::DefaultEnvironment>();
            let value = 750;
 
            test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
            test::set_value_transferred::<ink::env::DefaultEnvironment>(value);
            let id = contract.create_giftcard("Graduation".to_string()).unwrap();
 
            // Act
            let details = contract.get_giftcard(id).unwrap();
 
            // Assert
            assert_eq!(details.0, accounts.bob);
            assert_eq!(details.1, value);
            assert_eq!(details.2, "Graduation");
        }
 
        #[ink::test]
        fn get_giftcard_returns_none_for_invalid_id() {
            // Arrange
            let contract = setup_contract();
 
            // Act
            let result = contract.get_giftcard(42);
 
            // Assert
            assert_eq!(result, None);
        }
    }
 

Config file

Let’s not forget to update the package manager and build system Cargo.toml

[package]
name = "giftcard"
version = "0.1.0"
authors = ["Your Name <your.email@example.com>"]
edition = "2021"
 
[dependencies]
ink = { version = "5.1.1", default-features = false }
scale = { package = "parity-scale-codec", version = "3.7.4", default-features = false, features = ["derive"] }
scale-info = { version = "2.11.6", default-features = false, features = ["derive"], optional = true }
 
[lib]
name = "giftcard"
path = "lib.rs"
 
 
[features]
default = ["std"]
std = [
    "ink/std",
    "scale/std",
    "scale-info/std",
]
ink-as-dependency = []

Trade-offs of HashMap Approach

No Standardization:

  • No metadata standardization

Manual ID Management:

  • Requires custom ID tracking
  • No built-in uniqueness guarantees

No Transferability:

  • Simple ownership model
  • No built-in transfer function

Storage Efficiency:

  • Separate mappings for each property

Preview of the NFT-Based implementation

Why adopt NFTs?

Standardization:

  • PSP34 compliance for interoperability
  • Compatible with NFT marketplaces

Built-in functionality:

  • Automatic ownership tracking
  • Metadata support
  • Transfer functions

Enhanced features:

  • Royalty support
  • Provenance tracking
  • Collection management

Key differences to expect

Token-centric design:

#[ink(storage)]
pub struct Giftcard {
    psp34: psp34::Data,
    values: Mapping<Id, Balance>,
}

Standardized interface:

   impl PSP34 for Giftcard {
       // Inherit standard functions
   }

Metadata support:

   #[derive(Default, Debug)]
   pub struct GiftcardMetadata {
       value: Balance,
       created_at: Timestamp,
   }