Skip to content

Latest commit

 

History

History
293 lines (230 loc) · 10.1 KB

2008.md

File metadata and controls

293 lines (230 loc) · 10.1 KB

🏠 HOME

A Giftcard NFTs based

This chapter explains a custom NFT-like implementation for gift cards on Polkadot using ink! without OpenBrush or PSP34 (the NFT standard). This is a purpose-built solution for managing transferable gift cards with redeemable balances.

The exclusion of PSP34 is due to the fact that, as mentioned in the previous chapter, the OpenBrush library is no longer being updated, and a full implementation of PSP34 is beyond the scope of this tutorial.

Key Features

Feature This Implementation PSP34
Ownership Tracking Custom Mapping<u64, (AccountId, Balance)> PSP34Data struct
Transfer Logic Manual checks + event emission Standardized transfer
Redemption Burns NFT + transfers balance No native redemption
Interoperability None (custom) Cross-chain via XCM
Metadata None (balance-only) Optional via extensions

Implementation

The gift card smart contract enables users to lock assets into the contract through a simple transaction. Once the assets are locked, any user can redeem them.

There are two main functions:

  • Creating a Gift Card: When a user creates a gift card, a token is minted and the specified assets are sent to the smart contract.
  • Redeeming a Gift Card: To redeem the gift card, the token is burned, and the locked assets are transferred to the redeemer.

lib.rs

#![cfg_attr(not(feature = "std"), no_std, no_main)]
#![allow(unexpected_cfgs)] // Allows for contract-specific configurations

#[ink::contract]
mod gift_card {
    use ink::storage::Mapping;

    /// Custom error types for clear failure reporting
    #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    #[repr(u8)]
    pub enum ContractError {
        NotOwner = 1, // Caller doesn't own the card
        TransferFailed = 2, // Funds transfer failed
        InvalidCard = 3, // Card doesn't exist
        TransferToSelf = 4, // Attempted self-transfer
    }

    /// Tuple storing (owner_address, card_balance)
    type CardDetails = (AccountId, Balance);

    #[ink(storage)]
    #[derive(Default)]
    pub struct GiftCard {
        /// Maps card IDs to their details using efficient storage
        cards: Mapping<u64, CardDetails>,
        /// Auto-incrementing ID counter for new cards
        next_id: u64,
    }

    /// Event emitted when card ownership changes
    #[ink(event)]
    pub struct Transfer {
        #[ink(topic)]
        from: Option<AccountId>,
        #[ink(topic)]
        to: Option<AccountId>,
        #[ink(topic)]
        id: u64,
    }

    impl GiftCard {
        /// Initialize empty contract state
        #[ink(constructor)]
        pub fn new() -> Self {
            Self::default()
        }

        /// Create new gift card with transferred value
        #[ink(message, payable)]
        pub fn create(&mut self) -> u64 {
            let id = self.next_id;
            let caller = self.env().caller();
            let amount = self.env().transferred_value();

            self.cards.insert(id, &(caller, amount));
            self.next_id = self.next_id.saturating_add(1);
            id
        }

        /// Transfer card ownership (only current owner)
        #[ink(message)]
        pub fn transfer(&mut self, to: AccountId, id: u64) -> Result<(), ContractError> {
            let caller = self.env().caller();
            let (owner, balance) = self.cards.get(id).ok_or(ContractError::InvalidCard)?;

            // Security checks
            if caller != owner {
                return Err(ContractError::NotOwner);
            }
            if caller == to {
                return Err(ContractError::TransferToSelf);
            }

            // Update ownership
            self.cards.insert(id, &(to, balance));

            self.env().emit_event(Transfer {
                from: Some(caller),
                to: Some(to),
                id,
            });

            Ok(())
        }

        /// Redeem card balance and burn NFT
        #[ink(message)]
        pub fn redeem(&mut self, id: u64) -> Result<(), ContractError> {
            let caller = self.env().caller();
            let (owner, balance) = self.cards.get(id).ok_or(ContractError::InvalidCard)?;

            if caller != owner {
                return Err(ContractError::NotOwner);
            }

            // Cleanup and fund transfer
            self.cards.remove(id);
            self.env()
                .transfer(caller, balance)
                .map_err(|_| ContractError::TransferFailed)
        }

        /// View function to check card details
        #[ink(message)]
        pub fn get_card(&self, id: u64) -> Option<CardDetails> {
            self.cards.get(id)
        }
    }
}

Cargo.toml

[package]
name = "psp34_ob"
version = "0.1.0"
authors = ["Your Name <[email protected]>"]
edition = "2021"

[dependencies]
ink = { version = "4.2.1", default-features = false }
scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] }
scale-info = { version = "2.6", default-features = false, features = ["derive"], optional = true }
openbrush = { version = "4.0.0-beta", default-features = false, features = ["psp34"] }

[lib]
name = "psp34_ob"
path = "lib.rs"

[features]
default = ["std"]
std = [
    "ink/std",
    "scale/std",
    "scale-info/std",
    "openbrush/std",
]

ink-as-dependency = [] 

Why this isn’t PSP34

  1. No Standard Compliance:

    • Missing PSP34 traits (PSP34, PSP34Metadata, PSP34Mintable).
    • No support for cross-chain transfers (XCM) or batch operations.
  2. Simplified Design:

    • Uses u64 IDs instead of PSP34’s flexible Id type (supports bytes, vectors, etc).
    • No metadata support (unlike CIP25’s mandatory JSON schema).
  3. Native Value Handling:

    • Direct balance storage (like Cardano’s ADA in UTXOs) vs PSP34’s token-only approach.
  4. Burn Mechanism:

    • Explicit redemption vs PSP34’s optional PSP34Burnable extension.

When to use this approach

  • Simple Use Cases: Gift cards, vouchers, single-attribute tokens.
  • Prototyping: Faster than full PSP34 implementation.
  • Direct Value Handling: When NFTs need native token balances.

Testing locally and simulation on Testnet

Unit Tests

#[cfg(test)]
    mod tests {
        use super::*;
        use ink::env::{ test::*, DefaultEnvironment };

        #[ink::test]
        fn new_contract_initializes_correctly() {
            let contract = GiftCard::new();
            assert_eq!(contract.next_id, 0, "Should start with ID 0");
            assert_eq!(contract.get_card(0), None, "No cards should exist initially");
        }

        #[ink::test]
        fn full_lifecycle_with_transfer() {
            // Setup test environment
            let mut contract = GiftCard::new();
            let accounts = default_accounts::<DefaultEnvironment>();

            // Create card as Alice
            set_caller::<DefaultEnvironment>(accounts.alice);
            set_value_transferred::<DefaultEnvironment>(100);
            let card_id = contract.create();

            // Verify creation
            assert_eq!(
                contract.get_card(card_id).unwrap().0,
                accounts.alice,
                "Alice should own new card"
            );
            assert_eq!(contract.next_id, 1, "ID should increment");

            // Transfer to Bob
            set_caller::<DefaultEnvironment>(accounts.alice);
            contract.transfer(accounts.bob, card_id).expect("Transfer should succeed");

            // Verify transfer
            let (owner, _) = contract.get_card(card_id).unwrap();
            assert_eq!(owner, accounts.bob, "Bob should be new owner");

            // Redeem by Bob
            set_caller::<DefaultEnvironment>(accounts.bob);
            contract.redeem(card_id).expect("Redemption should succeed");

            // Post-redemption checks
            assert_eq!(contract.get_card(card_id), None, "Card should be burned");

            // Verify invalid operations
            assert_eq!(
                contract.redeem(card_id),
                Err(ContractError::InvalidCard),
                "Burned card should be invalid"
            );

            assert_eq!(
                contract.transfer(accounts.charlie, card_id),
                Err(ContractError::InvalidCard),
                "Burned card cannot transfer"
            );
        }
    }

While unit tests verify logic, testnet deployment reveals real-world behavior like gas fees, wallet interactions, and cross-account flows. Here’s how to replicate the gift card lifecycle across multiple users:

Step-by-Step real-world workflow

  1. Prerequisites:

    • Install Talisman or Polkadot.js Extension on two browsers (e.g., Chrome + Firefox).
    • Fund both wallets with testnet DOT:
      • Use Astar Faucet for Shibuya testnet (as explained in the 1st chapter)
  2. Deploy Contract (Browser 1):

    • Go to ink! Playground.
    • Upload gift_card.contract (compiled .contract bundle).
    • Deploy on Shibuya Testnet (Astar’s test parachain).
    • Save the contract address (e.g., 5Fj...9dQ).
  3. Simulate User A (Browser 1):

    • Connect Wallet 1 (e.g., Alice’s account).
    • Create Card: Call create() with some SBY (simulate value transfer).
    • Transfer Card: Call transfer() to Wallet 2’s address (Bob).
  4. Simulate User B (Browser 2):

    • Open ink! Playground in a new browser.
    • Connect Wallet 2 (Bob’s account).
    • Load Existing Contract: Paste the saved contract address.
    • Upload Metadata: Upload the JSON file representing the contract
    • Redeem Card: Call redeem() and verify balance increases.

🏠 HOME