Ticketing System
Non-fungible tokens (NFTs) are digital assets that represent ownership of unique items or pieces of content. They are commonly used for digital art, collectibles, in-game items, and other unique assets. In this tutorial, we will create a simple ticketing system based on NFT tokens.
Our contract will adhere to the CEP-95 standard, which is the standard for NFTs on the Casper blockchain.
Ticket Office Contract
Our TicketOffice contract will include the following features:
- Compliance with the CEP-78 standard.
- Ownership functionality.
- Only the owner can issue new event tickets.
- Users can purchase tickets for events.
- Tickets are limited to a one-time sale.
- Public access to view the total income of the
TicketOffice.
Setup the project
Creating a new NFT token with Odra is straightforward. Use the cargo odra new command to create a new project with the CEP-95 template:
cargo odra new --name ticket-office --template cep95
Contract implementation
Let's start implementing the TicketOffice contract by modify the code generated from the template.
use odra::{casper_types::{U256, U512}, prelude::*};
use odra_modules::access::Ownable;
use odra_modules::cep95::{CEP95Interface, Cep95};
pub type TicketId = U256;
#[odra::odra_type]
pub enum TicketStatus {
Available,
Sold,
}
#[odra::odra_type]
pub struct TicketInfo {
event_name: String,
price: U512,
status: TicketStatus,
}
#[odra::event]
pub struct OnTicketIssue {
ticket_id: TicketId,
event_name: String,
price: U512,
}
#[odra::event]
pub struct OnTicketSell {
ticket_id: TicketId,
buyer: Address,
}
#[odra::odra_error]
pub enum Error {
TicketNotAvailableForSale = 200,
InsufficientFunds = 201,
InvalidTicketId = 202,
TicketDoesNotExist = 203,
}
#[odra::module(
events = [OnTicketIssue, OnTicketSell],
errors = Error
)]
pub struct TicketOffice {
token: SubModule<Cep95>,
ownable: SubModule<Ownable>,
tickets: Mapping<TicketId, TicketInfo>,
token_id_counter: Var<TicketId>,
total_supply: Var<u64>,
}
#[odra::module]
impl TicketOffice {
pub fn init(&mut self, collection_name: String, collection_symbol: String, total_supply: u64) {
let caller = self.env().caller();
self.ownable.init(caller);
self.token.init(collection_name, collection_symbol);
}
pub fn issue_ticket(&mut self, event_name: String, price: U512) {
let env = self.env();
let caller = env.caller();
self.ownable.assert_owner(&caller);
// mint a new token
let ticket_id = self.token_id_counter.get_or_default();
self.token.mint(caller, ticket_id, Default::default());
// store ticket info
self.tickets.set(
&ticket_id,
TicketInfo {
event_name: event_name.clone(),
price,
status: TicketStatus::Available,
},
);
self.token_id_counter.set(ticket_id + 1);
// emit an event
env.emit_event(OnTicketIssue {
ticket_id,
event_name,
price,
});
}
#[odra(payable)]
pub fn buy_ticket(&mut self, ticket_id: TicketId) {
let env = self.env();
let owner = self.ownable.get_owner();
let buyer = env.caller();
let value = env.attached_value();
// only tokens owned by the owner can be sold
if self.token.owner_of(ticket_id) != Some(owner) {
env.revert(Error::TicketNotAvailableForSale);
}
let mut ticket = self
.tickets
.get(&ticket_id)
.unwrap_or_revert_with(&env, Error::TicketDoesNotExist);
// only available tickets can be sold
if ticket.status != TicketStatus::Available {
env.revert(Error::TicketNotAvailableForSale);
}
// check if the buyer sends enough funds
if value < ticket.price {
env.revert(Error::InsufficientFunds);
}
// transfer csprs to the owner
env.transfer_tokens(&owner, &value);
// transfer the ticket to the buyer
self.token.transfer_from(owner, buyer, ticket_id);
ticket.status = TicketStatus::Sold;
self.tickets.set(&ticket_id, ticket);
env.emit_event(OnTicketSell { ticket_id, buyer });
}
pub fn balance_of(&self) -> U512 {
self.env().self_balance()
}
}
- L7-L39 - We define structures and enums that will be used in our contract.
TicketStatusenum represents the status of a ticket,TicketInfostruct contains information about a ticket that is written to the storage,TicketIdis a type alias foru64.OnTicketIssueandOnTicketSellare events that will be emitted when a ticket is issued or sold. - L41-L44 - Register errors and events that will be used in our contract, required to produce a complete contract schema.
- L45-L51 -
TicketOfficemodule definition. The module contains aCep95token, anOwnablemodule, aMappingthat stores information about tickets andVarstoken_id_counterandtotal_supplyto keep track of the total number of tickets issued. - L55-L59 - The
initfunction has been generated from the template and there is no need to modify it, except theOwnablemodule initialization. - L61-L84 - The
issue_ticketfunction allows the owner to issue a new ticket. The function mints a new token, stores information about the ticket, and emits anOnTicketIssueevent. - L86 - The
payableattribute indicates that thebuy_ticketfunction can receive funds. - L87-L116 - The
buy_ticketfunction checks if the ticket is available for sale, if the buyer sends enough funds, and transfers the ticket to the buyer. Finally, the function updates the ticket status and emits anOnTicketSellevent.
Lets test the contract. The test scenario will be as follows:
- Deploy the contract.
- Issue two tickets.
- Try to buy a ticket with insufficient funds.
- Buy tickets.
- Try to buy the same ticket again.
- Check the balance of the contract.
use odra::{
casper_types::U512,
host::{Deployer, HostRef},
};
use crate::token::{Error, TicketOfficeHostRef, TicketOfficeInitArgs};
#[test]
fn it_works() {
let env = odra_test::env();
let init_args = TicketOfficeInitArgs {
collection_name: "Ticket".to_string(),
collection_symbol: "T".to_string(),
total_supply: 100,
};
let mut contract = TicketOffice::deploy(&env, init_args);
contract.issue_ticket("Ev".to_string(), U512::from(100));
contract.issue_ticket("Ev".to_string(), U512::from(50));
let buyer = env.get_account(1);
env.set_caller(buyer);
assert_eq!(
contract
.with_tokens(U512::from(50))
.try_buy_ticket(0.into()),
Err(Error::InsufficientFunds.into())
);
assert_eq!(
contract
.with_tokens(U512::from(100))
.try_buy_ticket(0.into()),
Ok(())
);
assert_eq!(
contract
.with_tokens(U512::from(50))
.try_buy_ticket(1.into()),
Ok(())
);
assert_eq!(
contract
.with_tokens(U512::from(100))
.try_buy_ticket(0.into()),
Err(Error::TicketNotAvailableForSale.into())
);
}
Unfortunately, the test failed. The first assertion succeeds because the buyer sends insufficient funds to buy the ticket. However, the second assertion fails even though the buyer sends enough funds to purchase the ticket. The buy_ticket function reverts with CEP-95 Error::NotAnOwnerOrApproved because the buyer attempts to transfer a token that they do not own, are not approved for, or are not an operator of.
fn transfer_from(&mut self, from: Address, to: Address, token_id: U256) {
self.assert_exists(&token_id);
let caller = self.env().caller();
let owner = self
.owner_of(token_id)
.unwrap_or_revert_with(self, Error::ValueNotSet);
// Only the owner or an approved spender can transfer the token.
if (owner != from || owner != caller) && !self.is_approved_for_all(from, caller) {
if let Some(approved) = self.approved_for(token_id) {
if approved != caller {
self.env().revert(Error::NotAnOwnerOrApproved);
}
} else {
self.env().revert(Error::NotAnOwnerOrApproved);
}
}
...
}
Let's fix it by redesigning our little system.
Redesign
Since a buyer cannot purchase a ticket directly, we need to introduce an intermediary — an operator who will be responsible for buying tickets on behalf of the buyer. The operator will be approved by the ticket office to transfer tickets.
The sequence diagram below illustrates the new flow:
Ticket Operator Contract
As shown in the sequence diagram, a new contract will act as an operator for the ticket office. To create this new contract, use the cargo odra generate command.
cargo odra generate -c ticket_operator
use crate::token::{TicketId, TicketOfficeContractRef};
use odra::{casper_types::{U256, U512}, ContractRef, prelude::*};
#[odra::odra_error]
pub enum Error {
UnknownTicketOffice = 300,
}
#[odra::module(errors = Error)]
pub struct TicketOperator {
ticket_office_address: Var<Address>,
}
#[odra::module]
impl TicketOperator {
pub fn register(&mut self, ticket_office_address: Address) {
self.ticket_office_address.set(ticket_office_address);
}
// now the operator's `buy_ticket` receives funds.
#[odra(payable)]
pub fn buy_ticket(&mut self, ticket_id: TicketId) {
let env = self.env();
let buyer = env.caller();
let value = env.attached_value();
let center = self
.ticket_office_address
.get()
.unwrap_or_revert_with(&env, Error::UnknownTicketOffice);
let mut ticket_contract = TicketOfficeContractRef::new(env, center);
// now and approved entity - the operator - buys the ticket on behalf of the buyer
ticket_contract.buy_ticket(ticket_id, buyer, value);
}
pub fn balance_of(&self) -> U512 {
self.env().self_balance()
}
}
- L4-L7 - Define errors that will be used in the contract.
- L9-L13 - Define the
TicketOperatormodule that stores the address of the ticketing office. - L16-L18 - The
registerfunction sets the address of the ticketing office. - L20-L32 - The
buy_ticketfunction buys a ticket on behalf of the buyer using the ticket office address. The function forwards the call to the ticketing office contract. We simply create aTicketOfficeContractRefto interact we theTicketOfficecontract. Note that, the operator'sbuy_ticketnow receives funds.
Now we need to adjust the TicketOffice contract to use the TicketOperator contract to buy tickets.
#[odra::odra_error]
pub enum Error {
...
MissingOperator = 204,
Unauthorized = 205,
}
#[odra::module]
pub struct TicketOffice {
...
operator: Var<Address>,
}
#[odra::module]
impl TicketOffice {
...
pub fn register_operator(&mut self, operator: Address) {
// only the owner can register an operator
let caller = self.env().caller();
self.ownable.assert_owner(&caller);
// store the ticketing center address in the operator contract
TicketOperatorContractRef::new(self.env(), operator).register(self.env().self_address());
self.operator.set(operator);
}
pub fn issue_ticket(&mut self, event_name: String, price: U512) {
// minting logic remains the same...
...
// approve the operator to transfer the ticket
let operator = self.operator();
self.token.approve(operator, ticket_id);
// emit an event
...
}
pub fn buy_ticket(&mut self, ticket_id: TicketId, buyer: Address, value: U512) {
let env = self.env();
let owner = self.ownable.get_owner();
let caller = env.caller();
// make sure the caller is the operator
if !self.is_operator(caller) {
env.revert(Error::Unauthorized);
}
...
// the logic remains the same, except for the csprs transfer
// it is now handled by the operator contract.
// env.transfer_tokens(&owner, &value);
}
#[inline]
fn is_operator(&self, caller: Address) -> bool {
Some(caller) == self.operator.get()
}
#[inline]
fn operator(&self) -> Address {
self.operator
.get()
.unwrap_or_revert_with(&self.env(), Error::MissingOperator)
}
}
- L11 - the contract stores the operator address.
- L18-L25 - a new function
register_operatorallows the owner to register an operator. Also calls theregisterentry point on the operator contract. - L32-36 - modify the
issue_ticketfunction: once a new token is minted, approves the operator to transfer the ticket later. - L40-L53 - modify the
buy_ticketfunction: check if the caller is the operator, do not transfer cspr to the contract - now the operator collect funds. - We also added two helper functions:
is_operatorandoperatorto check if the caller is the operator and get the operator address. Two new errors were added:MissingOperatorandUnauthorized.
Now we need to update our tests to create a scenario we presented in the sequence diagram.
use odra::{
casper_types::U512,
host::{Deployer, HostRef, NoArgs},
prelude::*
};
use crate::{
ticket_operator::TicketOperatorHostRef,
token::{Error, TicketId, TicketOfficeContractRef, TicketOfficeInitArgs},
};
#[test]
fn it_works() {
let env = odra_test::env();
let init_args = TicketOfficeInitArgs {
collection_name: "Ticket".to_string(),
collection_symbol: "T".to_string(),
total_supply: 100,
};
let operator = TicketOperator::deploy(&env, NoArgs);
let mut ticket_office = TicketOfficeContractRef::deploy(&env, init_args);
ticket_office.register_operator(operator.address().clone());
ticket_office.issue_ticket("Ev".to_string(), U512::from(100));
ticket_office.issue_ticket("Ev".to_string(), U512::from(50));
let buyer = env.get_account(1);
env.set_caller(buyer);
assert_eq!(
buy_ticket(&operator, 0.into(), 50),
Err(Error::InsufficientFunds.into())
);
assert_eq!(buy_ticket(&operator, 0.into(), 100), Ok(()));
assert_eq!(buy_ticket(&operator, 1.into(), 50), Ok(()));
assert_eq!(
buy_ticket(&operator, 0.into(), 100),
Err(Error::TicketNotAvailableForSale.into())
);
assert_eq!(operator.balance_of(), U512::from(150));
}
fn buy_ticket(operator: &TicketOperatorHostRef, id: TicketId, price: u64) -> OdraResult<()> {
operator.with_tokens(U512::from(price)).try_buy_ticket(id)
}
Conclusion
In this tutorial, we created a simple ticketing system using the CEP-95 standard. This guide demonstrates how to combine various Odra features, including modules, events, errors, payable functions, and cross-contract calls.