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.
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 |
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 = []
-
No Standard Compliance:
- Missing PSP34 traits (
PSP34
,PSP34Metadata
,PSP34Mintable
). - No support for cross-chain transfers (XCM) or batch operations.
- Missing PSP34 traits (
-
Simplified Design:
- Uses
u64
IDs instead of PSP34’s flexibleId
type (supports bytes, vectors, etc). - No metadata support (unlike CIP25’s mandatory JSON schema).
- Uses
-
Native Value Handling:
- Direct balance storage (like Cardano’s ADA in UTXOs) vs PSP34’s token-only approach.
-
Burn Mechanism:
- Explicit redemption vs PSP34’s optional
PSP34Burnable
extension.
- Explicit redemption vs PSP34’s optional
- Simple Use Cases: Gift cards, vouchers, single-attribute tokens.
- Prototyping: Faster than full PSP34 implementation.
- Direct Value Handling: When NFTs need native token balances.
#[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:
-
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)
-
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
).
-
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).
-
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.