Editor’s Note: I’ve been utilizing the Mirror blogging platform primarily because it signals that I’m a cool crypto guy. However, it’s a bit lacking in functionality. Several folks have expressed an interest in a mechanism to ask questions/post comments on each episode. As such, I’m going to move this blog to Substack (not paid) starting with the next episode. Until then, feel free to hit me up at Twitter or on the Aptos discord. I’ll continue crosspost here on Mirror because I am, in fact, very much a cool crypto guy.
Learning Objectives: Global Storage operators, External Modules, Vectors, Generics, Acquires
We’re picking up where we left off at the last episode - so if this is your first time here check that out first. As always, the repo for the code as it should be at the end of this episode is tagged “Episode-2”.
Last episode we learned how to create a ConcertTicket resource and enable a user to create the ticket and transfer it to their account with:
public fun create_ticket(recipient: &signer, seat: vector<u8>, ticket_code: vector<u8>) {
move_to<ConcertTicket>(recipient, ConcertTicket {seat, ticket_code})
}
While that’s interesting, I’d rather sell tickets to the concert. And we really don’t want the user to be able to assign their seat or everyone’s going to be in the front row. So, let’s jump right in.
We’re going to add several new error constant declarations, so let’s add them just after the structs as:
const ENO_VENUE: u64 = 0;
const ENO_TICKETS: u64 = 1;
const ENO_ENVELOPE: u64 = 2;
const EINVALID_TICKET_COUNT: u64 = 3;
const EINVALID_TICKET: u64 = 4;
const EINVALID_PRICE: u64 = 5;
const EMAX_SEATS: u64 = 6;
const EINVALID_BALANCE: u64 = 7;
The first thing we need to figure out how to do is to limit the actual tickets that can be bought/sold. In the physical world, that’s a fairly easy constraint to comprehend: a venue only has so many physical seats it can sell tickets for. You can’t sell two tickets to the same seat (unless you are United Airlines apparently). Let’s continue to model our resources after their physical world counterpart and create a new struct we’ll call Venue:
struct Venue has key {
available_tickets: vector<ConcertTicket>,
max_seats: u64
}
Add that code to the top of your module just past the ConcertTicket struct. Venue is altogether different from our ConcertTicket struct, with the new twist of a vector of another struct. A venue could be anything from a small club with a handful of seats to a 50,000 seat stadium. Let’s give a venue owner the ability to create a Venue in their Aptos account/wallet:
public fun init_venue(venue_owner: &signer, max_seats: u64) {
let available_tickets = Vector::empty<ConcertTicket>();
move_to<Venue>(venue_owner, Venue {available_tickets, max_seats})
}
This introduces two new concepts that we’ll cover individually: Std::Vector and Generics.
(If you don’t get that subtitle reference, we can’t be friends)
Vector is part of the standard library that comes from the Move language. While vector<T>
is a native primitive type, the Vector module adds some wrappers that makes it easier for us to do things with resources. There is a pretty good intro here:
I overheard my 16 year-old daughter ask a friend of hers “what’s the tea?” while discussing some high school drama - to which I quickly replied, “A generic class or interface that is parameterized over types is typically represented as T in the interface definition.” Apparently, I was incorrect answering “what’s the T” in her context and my helpful answer was not appreciated.
Most of you will be familiar with the <T>
notation, but if it’s the first time you’re seeing it, just think of <T>
as a parameter for types. In our Move context, we call that a Generic. We sort of glossed over that topic in Episode 1 and there were a few questions.
Since Vector is part of a library, we have to tell the compiler we’re going to use it. In your code just under Std::Signer, let’s add:
use Std::Signer;
use Std::Vector;
In our init_venue
function above, we are calling the function Std::Vector::empty<T>()
which creates an empty vector<T>
. If we peek into the Vector.move module at in the repo at aptos-core/aptos-move/framework/move-stdlib/sources/Vector.move, we can see that Vector::empty
is just a function:
// Create an empty vector.
native public fun empty<Element>(): vector<Element>;
Wait, what happened to the <T>
? Well, generics are so generic we don’t even specify what you have to call them. Whatever name you put inside the < >
in essence becomes a variable that refers to a Type. When a function is defined as:
native public fun empty<Element>(): vector<Element>;
whatever Type we pass in the first element is represented in the rest of the function. So, dropping in <ConcertTicket>
for <Element>
, the actual function basically becomes:
native public fun empty<ConcertTicket>(): vector<ConcertTicket>;
and anywhere else <Element>
appeared in the function would become <ConcertTicket>
. That’s all handled behind the scenes for us and we don’t have to worry about it. When defining your own functions with generic type parameters, you can call the parameter T, or Element, or WhateverIDarnWellPlease. It’s just a variable name that holds a type.
So we’ve created a vector of ConcertTicket with
let available_tickets = Vector::empty<ConcertTicket>();
Then we create the Venue resource and move it into our account:
move_to<Venue>(venue_owner, Venue {available_tickets, max_seats})
Now, we could combine those two into one line of code with:
move_to<Venue>(venue_owner, Venue {available_tickets: Vector::empty<ConcertTicket>(), max_seats})
But I think it’s more readable splitting into two lines. Also, to make a very minor point, when setting the property value of a struct, I can use a variable named available_tickets
and pass it to Venue {available_tickets}
and it will work fine. Because the variable is named the same thing as the parameter, what the compiler sees is Venue {available_tickets: available_tickets}
. If you’re passing in anything else, you’ve got to explicitly reference the property name like we did in the one line version above.
Let’s test this and make sure it’s functioning. To start, let’s delete all of our test code from last episode:
#[test(recipient = @0x1)]
public(script) fun sender_can_create_ticket(recipient: signer){
....
}
and replace it with this starting point:
#[test(venue_owner = @0x1)]
public(script) fun sender_can_buy_ticket(venue_owner: signer) {
let venue_owner_addr = Signer::address_of(&venue_owner);
// initialize the venue
init_venue(&venue_owner, 3);
assert!(exists<Venue>(venue_owner_addr), ENO_VENUE);
}
We’ve setup our test to run with an account venue_owner
which we are assigning address 0x1 and then we call the init_venue
function to create the venue with a maximum of 3 seats. We use our exists
function again to make sure the venue was created. From the terminal, run cargo tests
and you’ll pass all green.
We can call the init_venue
function from our account and we now own an empty arena. Let’s create some tickets to sell. Since we have a maximum number of tickets we can create, it would be helpful to know how many tickets we have already created. Let’s add a helper function of:
public fun available_ticket_count(venue_owner_addr: address): u64 acquires Venue {
let venue = borrow_global<Venue>(venue_owner_addr);
Vector::length<ConcertTicket>(&venue.available_tickets) }
We’ve got several new things going on here. Let’s look at ‘acquires’ first. I’ve got three kids, aged 16, 17 and 21 (yes, I’m old). When each of them first started driving on their own, we had a rule that they always had to tell us where they were going. On occasion, I would use that ever so handy “Find My” iPhone app to see where they were (and they were aware of this). If they ever popped up in a location they hadn’t told me about, I’d give them a call with “I see you are in this location, but you didn’t tell me you were going there. Why did you not want me to know you were going to be there?” It was a temporary practice to ensure their new found freedom wasn’t enabling bad decision making.
In essence, that’s what ‘acquires’ does in the Move language. Move is built for safety. If you intend to access a Venue type from global storage, you’ve got to state that with acquires Venue
- otherwise compiler dad is going to call you with The call acquires 'TicketTutorial::Tickets::Venue', but the 'acquires' list for the current function does not contain this type. It must be present in the calling context's acquires list
and your code won’t build. Couldn’t the compiler just infer the ‘acquires’ based on my code? Well, sure, but that doesn’t accomplish the safety objective any more than inferring my son is supposed to be at the lake during school hours just because that’s where his phone shows up on the map. It’s a compiler check to make sure what we’re doing in code is what we intended. There is a detailed explanation here:
Now that compiler dad knows where we’re going to be with our ‘acquires’, we can see how many tickets we’ve created. The borrow_global
is a global storage operator that allows us to read a particular data type from an account’s global storage. The line borrow_global<Venue>(venue_owner_addr)
lets us read the Venue
struct belonging to the account owner. We don’t have to specify an index or any other reference because Move will only allow us to create one of any struct at an address. We can either have no Venues or we can have one. That’s it. The ‘borrow’ process ensures data integrity. We can’t borrow a resource if it’s already been borrowed somewhere else. This ensures that we are always getting accurate data. We’ll see this at play shortly.
Key Point: An account can only hold one instance of any resource type.
We’ve got our Venue resource sitting in the aptly named variable venue
, now we want to see how many tickets are in the available_tickets
property. The function Vector::length
gives us that number by passing in the vector we want the count of. Since the vector we want to know about contains ConcertTickets, we pass in the struct as the type parameter and a reference to the available_tickets
property of venue
. You probably noticed there is no explicit return
statement for our function. In the Move language, when the last line of a function returns a value (and we don’t end the line with a semicolon), that value is returned as the value of the function. In essence, the last line of:
Vector::length<ConcertTicket>(&venue.available_tickets)
is actually compiled as if it were:
return Vector::length<ConcertTicket>(&venue.available_tickets);
It’s a handy feature that saves us a tiny bit of time.
We’ve got our helper function now, so let’s create some tickets. We’ll modify our previous create_ticket
function from Episode 1 to:
public fun create_ticket(venue_owner: &signer, seat: vector<u8>, ticket_code: vector<u8>, price: u64) acquires Venue {
let venue_owner_addr = Signer::address_of(venue_owner);
assert!(exists<Venue>(venue_owner_addr), ENO_VENUE);
let current_seat_count = available_ticket_count(venue_owner_addr);
let venue = borrow_global_mut<Venue>(venue_owner_addr);
assert!(current_seat_count < venue.max_seats, EMAX_SEATS);
Vector::push_back(&mut venue.available_tickets, ConcertTicket {seat, ticket_code, price});
}
The first thing you’ll notice is we’ve added a new property to ConcertTicket
with price
since not all seats will have the same price. So we need to modify our struct to:
struct ConcertTicket has key, store, drop {
seat: vector<u8>,
ticket_code: vector<u8>,
price: u64
}
In the create_ticket
function, we first make sure that the Venue has been created, then we grab the number of seats currently created with our available_ticket_count
function. We need to do stuff with Venue
again, but this time we use borrow_global_mut
instead of borrow_global
. The only difference here is we are saying we need this value from global storage, and we intend to change something about it. We then check to make sure we’re still under the max_seats property of the Venue with our assert!
.
Finally, we can create a ticket and add it to our available_tickets
property with the call to Vector::push_back
which appends the new ticket to the end of the vector. Note, in the call to push_back
we pass a mutable reference to available_tickets
so the function call can make changes to it.
Let’s modify our test and add three seats to our arena with this code just after creating and checking the venue:
assert!(exists<Venue>(venue_owner_addr), ENO_VENUE);
// create some tickets
create_ticket(&venue_owner, b"A24", b"AB43C7F", 15);
create_ticket(&venue_owner, b"A25", b"AB43CFD", 15);
create_ticket(&venue_owner, b"A26", b"AB13C7F", 20);
// verify we have 3 tickets now
assert!(available_ticket_count(venue_owner_addr)==3, EINVALID_TICKET_COUNT);
Run the tests with cargo run
from terminal and we get all green. All is well; we’ve got three tickets.
Let’s do something just to make a learning point. Rearrange the order of the lines in our create_ticket function so that we set the venue
variable before we set current_seat_count
:
assert!(exists<Venue>(venue_owner_addr), ENO_VENUE);
let venue = borrow_global_mut<Venue>(venue_owner_addr); let current_seat_count = available_ticket_count(venue_owner_addr);
If you run cargo test
again, you’ll get an error:
error[E07003]: invalid operation, could create dangling a reference
┌─ /Users/culbrethw/Development/Tutorials/Tickets/sources/TicketTutorial.move:39:28
│ 38 │ let venue = borrow_global_mut<Venue>(venue_owner_addr);
│ ------------------------------------------ It is still being mutably borrowed by this reference
39 │ let current_seat_count = available_ticket_count(venue_owner_addr);
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Invalid acquiring of resource 'Venue'
What happened? Since we have borrowed <Venue>
from global storage, no one else can borrow it until we’re done. Our current_seat_count
function tries to borrow that same <Venue>
and the safety checks in Move just won’t allow it. Go ahead and change the order back to how we had it.
Can we sell these yet? Almost - but let’s help our potential buyers by letting them find out how much a seat costs. We’ll create another helper function with:
fun get_ticket_info(venue_owner_addr: address, seat:vector<u8>): (bool, vector<u8>, u64, u64) acquires Venue {
let venue = borrow_global<Venue>(venue_owner_addr);
let i = 0;
let len = Vector::length<ConcertTicket>(&venue.available_tickets);
while (i < len) {
let ticket= Vector::borrow<ConcertTicket>(&venue.available_tickets, i);
if (ticket.seat == seat) return (true, ticket.ticket_code, ticket.price, i);
i = i + 1;
};
return (false, b"", 0, 0)
}
The first thing to note - without the explicit public
in our function declaration, we’ve made this a private function that can only be called within the module. This allows us to control which part of the ticket info we make available to users with wrapper functions. We’re also returning four different values of bool, vector<u8>, u64, u64
so that we can return success/failure of the function, the ticket_code, the price and the index of where the seat in question sits in the available_tickets
. (side note: the code would be more readable in my opinion if we could declare the return with something like ‘success: bool, ticket_code: vector …’ but the Move compiler doesn’t seem to like that - or I haven’t figured out how to do it yet.)
To let the user query prices for a particular seat, we’ll create a wrapper function as:
public fun get_ticket_price(venue_owner_addr: address, seat:vector<u8>): (bool, u64) acquires Venue {
let (success, _, price, _) = get_ticket_info(venue_owner_addr, seat);assert!(success, EINVALID_TICKET);
return (success, price)
}
One new thing here is the use of the _
notation in place of a variable we’re receiving. The underscore tells the compiler “I know I’ve got to stash this return value somewhere, but I have no plans on using it, so let’s both agree just to trash it.” You can use the underscore by itself, or in front of a variable name like (success, _ticket_code, price, _index)
to make the code more readable, but the end result is the same. One related note - because we are discarding values we pulled from a struct, that struct must have the “drop” capability, which you may have noticed we added to ConcertTicket at the top of this episode.
Let’s test this functionality now by adding the following to our test code:
// verify seat and price
let (success, price) = get_ticket_price(venue_owner_addr, b"A24");
assert!(success, EINVALID_TICKET);
assert!(price==15, EINVALID_PRICE);
We want to know about seat “A24”. The return value in success
lets us know that the ticket exists, and then we can make sure the price is the same ‘15’ that we set it to when we created the ticket.
We’re almost ready to sell some tickets. We could simply create a function to receive tokens as the purchase price and do a move_to
to transfer a ticket to a buyer. Remember, though, we can only have one of any resource type in an account. So if our buyer wants to buy more than one ticket, we need to create a resource to hold multiple tickets. Let’s create a new struct with:
struct TicketEnvelope has key {
tickets: vector<ConcertTicket>
}
This gives us a resource we can create for the buyer and hold multiple ConcertTickets. We can (finally) purchase the tickets by adding this function:
public fun purchase_ticket(buyer: &signer, venue_owner_addr: address, seat: vector<u8>) acquires Venue, TicketEnvelope {
let buyer_addr = Signer::address_of(buyer);
let (success, _, price, index) = get_ticket_info(venue_owner_addr, seat);
assert!(success, EINVALID_TICKET);
let venue = borrow_global_mut<Venue>(venue_owner_addr);
TestCoin::transfer_internal(buyer, venue_owner_addr, price);
let ticket = Vector::remove<ConcertTicket>(&mut venue.available_tickets, index);
if (!exists<TicketEnvelope>(buyer_addr)) {
move_to<TicketEnvelope>(buyer, TicketEnvelope {tickets: Vector::empty<ConcertTicket>()});
};
let envelope = borrow_global_mut<TicketEnvelope>(buyer_addr);
Vector::push_back<ConcertTicket>(&mut envelope.tickets, ticket);
}
This function lets a buyer
purchase a ticket from a specific venue and specify the seat they want to purchase. We first check if that’s a valid seat by calling the get_ticket_info
. If it is valid, we now also have the price
.
We introduce a new external function with TestCoin::transfer_internal
. This module is part of AptosFramework
which you can find in the repo at /aptos-core/aptos-move/framework/aptos-framework/sources/TestCoin.move. (Side note: you should really spend some time getting to know all of the modules in aptos-move/framework). To use this function, we need to declare it at the top of the module with:
use Std::Signer;
use Std::Vector;
use AptosFramework::TestCoin;
The venue owner receives the purchase price from the buyer through the transfer_internal
function, which is declared in TestCoin.move as:
public fun transfer_internal(from: &signer, to: address, amount: u64)
To transfer an amount of TestCoin from buyer
of course requires the buyer’s signature. But we only require the address of venue_owner
as the destination of the transfer? Doesn’t this violate the Move principle of ‘you can’t send resources to an account without a signature’? In short: no. But this is an important nuance of that idea. Before I can transfer TestCoin to any account, I’ve got to call TestCoin::register
with that account, which will create a TestCoin::Balance
resource in the account to hold TestCoin for me. A call to register
requires a signature. Once that is done, TestCoin
as a module is basically all powerful with that resource in the account. That’s why we don’t need a signature from the destination to transfer the coin because the initial signature creating the resource in register
in essence agrees to let TestCoin run the show with that resource going forward.
Why is this such a critical learning point? Well, I could very easily create a function like (but don’t add this because we aren’t going to use it):
public fun revoke_ticket(buyer_addr: address, venue_owner_addr: address) acquires Venue, TicketEnvelope {
let venue = borrow_global_mut<Venue>(venue_owner_addr);
let envelope = borrow_global_mut<TicketEnvelope>(buyer_addr);
let ticket = Vector::pop_back<ConcertTicket>(&mut envelope.tickets);
Vector::push_back<ConcertTicket>(&mut venue.available_tickets, ticket)
}
That requires no signatures, but I could take tickets away from buyer
without giving them back the TestToken they have already paid. The buyer certainly isn’t going to like this. The fundamental principle is this:
Key point: Move has protections against developers being dumb. Move does not have protections against developers being evil.
With all of the efforts to make Move safe, we cannot prevent developers with ill intent. So, we still have to trust the source of the smart contract. We still need to audit code. We don’t want to blindly interact with a module if we don’t fully know what’s inside. Beware of any team who wants to keep all of their code in private repos.
After our TestCoin transfer, we remove the ticket the buyer wants from our available pool with the Vector::remove
function call. We specify the exact ticket we want through the index
variable we received when we called get_ticket_info
. That ticket now exists inside the variable ticket
and is no longer present in available_tickets
. We have to do something with that value now. If this is the first ticket the buyer has purchased, we need to create the TicketEnvelope
resource and move it to their account, which we do after checking the existence of TicketEnvelope
in our assert!
. Once we are assured we have a place to send it, we add the ticket
to the buyer’s TicketEnvelope
just like we did when we created the ticket for the venue.
Let’s expand our test code to make sure all of this functions. Start by modifying the declaration as:
#[test(venue_owner = @0x1, buyer = @0x2, faucet = @CoreResources)]
public(script) fun sender_can_buy_ticket(venue_owner: signer, buyer: signer, faucet: signer) acquires Venue, TicketEnvelope {
The unfamiliar piece here is the introduction of faucet
and CoreResources
. In unit testing, we aren’t actually doing anything on devnet (or any net), so we need a way to access TestCoin and a faucet to give our test account resources. By passing in the system address of @CoreResources
we can pretend we own the TestCoin module for unit testing. I was initially stumped on how to do this; thanks to Zekun (again) from the Aptos team for the assist (again) on helping me understand this (Zekun is like the John Stockton of blockchain development - and if you don’t get that reference, we definitely can’t be friends).
Let’s setup our accounts to buy tickets by adding the following code to the end of our test:
// initialize & fund account to buy tickets
TestCoin::initialize(&faucet, 1000000);
TestCoin::register(&venue_owner);
TestCoin::register(&buyer);
let amount = 1000;
let faucet_addr = Signer::address_of(&faucet);
let buyer_addr = Signer::address_of(&buyer); TestCoin::mint_internal(&faucet, faucet_addr, amount);
TestCoin::transfer(faucet, buyer_addr, 100);
assert!(TestCoin::balance_of(buyer_addr) == 100, EINVALID_BALANCE);
Here' we are initializing our TestCoin module with our imposter faucet account. Then we have to register both venue_owner
and buyer
to receive TestCoin. We initially mint coin internally to TestCoin then transfer 100 to buyer
. We make sure this worked correctly by checking the balance.
Now, let’s test the ticket purchase adding:
// buy a ticket and confirm account balance changes
purchase_ticket(&buyer, venue_owner_addr, b"A24");
assert!(exists<TicketEnvelope>(buyer_addr), ENO_ENVELOPE);
assert!(TestCoin::balance_of(buyer_addr) == 85, EINVALID_BALANCE);
assert!(TestCoin::balance_of(venue_owner_addr) == 15, EINVALID_BALANCE);
assert!(available_ticket_count(venue_owner_addr)==2, EINVALID_TICKET_COUNT);
Our buyer
testing account is purchasing seat A24 through purchase_ticket
. We then make sure a TicketEnvelope resource was created for the user, ensured the balances of both accounts changed by the 15 purchase price and then confirmed that the available ticket count went from 3 down to 2. Give it a run with cargo test
and all green! Hurray, we bought a ticket!
Let’s run one more test adding:
// buy a second ticket & ensure balance has changed by 20
purchase_ticket(&buyer, venue_owner_addr, b"A26");
assert!(TestCoin::balance_of(buyer_addr) == 65, EINVALID_BALANCE);
assert!(TestCoin::balance_of(venue_owner_addr) == 35, EINVALID_BALANCE);
We’re simply buying a second ticket, seat A26. We know this one is more expensive, so our balance should change by 20 on each account. Another cargo test
and all green again! Buyer now owns two tickets and the Venue has collected 35 TestCoin. Hurray!
At this point you might be thinking, “Did we just sell an NFT?? Because I’ve seen tickets to events as NFTs and this sort of looks like an NFT.” Well, the answer to that is “yes and no” but mostly “no”. Disclaimer: the remainder of this episode is primarily editorial, so if you want to avoid my pontificating, feel free to skip this section. But if you do you’ll probably be a failure as a developer the rest of your life.
What exactly is an NFT? The immediate response may be “non-fungible token”. While sort of correct in regards to the genesis of the term, I can tell you without a doubt that there isn’t much that is actually “non-fungible” in most NFTs. On pretty much all chains, beyond the address of the token and maybe a few properties, most of what makes an NFT an NFT can be broadly changed at the whim of the developer. They may store that data on Arweave and call it non-fungible, but the pointer to that Arweave data from the originating token can generally be modified.
The reality is that the term NFT has become a catch-all for digital assets, the most typical variety of which is the PFP image NFT. So in that sense, you might could get away with calling our ConcertTicket an NFT because it is a digital asset. That’s not a great pathway, though. What has really become the de-facto definition of an NFT is any digital asset that conforms to chain/community standards on representing information about the NFT. On Ethereum, that’s the ERC-721 standard. On Solana, that’s become the Metaplex standard (which is “ERC-721-ish”). Several marketplaces have popped up to facilitate buying/selling digital assets with these standards as the data schema.
What has evolved is more and more digital assets are trying to force themselves into the NFT mold that was made for images. Part of that is due to the NFT standards, but part of it is due to the fact that it’s just not very easy to create bespoke digital assets with any level of design creativity on most chains. The development pathways tend to be limited in what you can do. I’ll likely publish a full analysis of this idea in another blog post (non-tutorial) in the future. The point here, though, is this:
Key Point: Do not bring preconceived architectural constraints from other chains into Move because they generally don’t apply here.
Certainly there will be a number of efforts to be the first Aptos NFT or the first Aptos Defi - but those use cases don’t even scratch the surface of what we can build here. This is a big part of why my team is likely moving our development to Aptos. We are creating a protocol for a unique digital asset that just doesn’t fit into the parameters of current NFT standards. While we’ve been mostly in “stealth-mode” to date (I’m not a fan of that term), we’ll be announcing our “reveal” on this blog in a week or two.
Congrats on making it to the end of yet another wordy post. As always, repo for the code as it should be at this point is here with the version of each episode published under its own tag. In about a week or so, with ‘Episode 3: Deploy Things’, we’ll publish to devnet and start building a UI around these ideas.