Tickets for the dummies
Tezos tickets are a built-in generic data type, with strong invariants enforced by the type system. That’s a mouthful and doesn’t convey much without context. Tickets: what, why, how? What makes tickets tick?
So let’s talk about tickets. And let’s not assume any prior knowledge about linear types, michelson primitives or the edo protocol period, and let’s take our time. First, let’s give some context.
What is a token?
In Tezos, there is a single built-in token: the XTZ. You can own it, you can send it to someone else, use it in contracts, and so on. It fuels every transaction, every smart contract execution, the tokenomics in general, and is all around essential to the blockchain. But let’s not focus on that, and think of it as a token.
All other tokens are just an illusion abstraction: they are not something “in your wallet”, 'cause they are whatever their contract says they are. So much so that we talk about FA1.2, FA2 or ERC-xxx tokens, but those are in fact contract standards:
1. a generic interface,
2. a metadata standard,
3. some guidelines about invariants to enforce (what is expected).
Basically, a token is just a row in the ledger of a particular contract with standardized entrypoints that, hopefully, behave nicely. We’ll call it… the token contract (token SC for short).
If you want to trade a token, you need to interact with the token contract. The same goes for exchanges, marketplaces, dapps, etc., they all need to interact with the token contract. In fact, that is the whole point of standardizing token SCs: other contracts know how to interact with token contracts because the interface is defined in advance. Otherwise, an exchange would need to create new bridges for each and every new token; that is, write new code to interact with the token contract. This is not infeasable, but it is redundant, painful, confusing, has a bad UX, and above all silly.
You own a token only in the sense that this particular contract has you, on the record, in the ledger. The token is not in your wallet, rather, your wallet asks the token contract which tokens you have. You don’t send the token, you ask the token contract to change the corresponding lines in the ledger. The properties of a token are derived from the implementation of the contract: they are not intrinsic properties of a token, but depend on the code and execution of the smart contract. A token can not be duplicated, stolen or lost, only because the token contract enforces those properties.
Modeling a token by a single smart contract (or set of), that requires all actions on token to transit by the token contract adds a few complications hidden behind ill-crafted vocabulary (e.g. “token in your wallet”). Here are some examples.
The token contract is a single point of failure. This is not a direct security concern, because the contract is public, and one can check that the semantic of the token is sound (no bugs), and won’t change (no trickery). Also, hopefully, it’s a much smaller trusted computing base than standard software (ignoring the blockchain protocol implementation). Of course “no change” can be a problem in itself, if the original contract is not perfect. To fix that, a bit of trickery can be added (e.g. wrappers), but then, either trust is required (which limits application), or a governance model of some kind (which adds complexity).
As everything done with the token goes through the token contract, the whole ecosystem in which the token moves is heavily centralized. What one can see as a simple transaction between users, exchanging tokens, actually involves multiple transactions between both users and the token contract: checks, transfer, and on top of that transactions between the users to orchestrate the transaction, dealing with failure for instance. Of course, a real use case can get way more tricky than a bunch of direct transactions as it could involve dApps, exchanges, or a number of more complex actions. On top of the complexity of UX, there is a direct gas consumption overhead inherent to centralization, that is completely obfuscated by the simple abstraction of a “token in your wallet”.
Comparison between XTZ and other tokens
To recap, let’s compare tokens based on smart contracts with the built-in XTZ on a few key points.
Storage and Gas OverHead are self explanatory: a contract call will always be more costly (in space and time) than a simple XTZ transfer.
Built in means has native support in protocol: it’s statically typed, has dedicated instructions, etc.
There are two components that require trust:
- ownership, e.g. the minimum we need to trust/examine/verify to convince ourselves that a token can not be stolen (obviously, trusting a SC entails also trusting the protocol)
- token value, e.g we can convince ourselves that nobody can suddenly generate a large amount of a fungible token from thin air
What is a ticket?
In short, tickets are tokens in your wallets, for real this time, and as first-class citizens in smart contracts. They still need to be created by a contract, and need dApps to be useful, but they can be stored outside the contract that created them, be attached to any account, and be exchanged reliably just like XTZ (almost).
In more details
First of all, tickets are values of the smart contract language: 'a ticket in CameLIGO, or ticket cty in Michelson, is a built-in generic data type with specific constructors. A value of type t ticket for any comparable type t:
- is a value manipulated directly by the contract,
- can be send by contracts to other contracts,
- can carry information.
Tickets have strong invariants enforced by the type system that give any ticket a semantic and a value shared by the whole blockchain:
- it cannot be duplicated (for a specific definition of duplicated),
- it cannot be altered (for a specific definition of altered),
- it cannot be forged (for a specific definition of forged).
A contract trying to do any of those things will be rejected by the type system. Tickets are linear types[1:1], and so are any type containing tickets. For instance, in LIGO, a variable containing a ticket can only be used once. More on that later.
For now, let’s just say that the type system does not allow duplication, which means that if a contract sends a ticket somewhere, it cannot keep a copy, and everybody, users and smart contracts alike, can rely on that. That is not to say that each ticket is completely unique: the original creator of the ticket can create any number of indistinguishable tickets, but nobody else can.
A ticket cannot be forged, in that it’s impossible to create or alter a ticket and pretend that it comes from somewhere else. More on that later.
For now, remember that it’s always possible to reliably know where a ticket comes from, and to trust nobody tampered with it.
Those properties together make tickets a natural choice for implementing tokens:
- can be sent + cannot be duplicated = ownership can be transfered
- shared semantic + arbitrary value = generic token
Moreover, they can change the way to implement dealings with tokens. As an example, there is never a need to check that a user owned a token : it is directly provided and can be manipulated directly.
A ticket autopsy
The information contained in a ticket are:
- the ticketer: the address of the contract that created the ticket,
- a payload: a arbitrary value of an arbitrary comparable type, given when the ticket was created,
- and the amount: a natural number, representing the “volume” or “weight” of a ticket. When you join two compatible tickets of amount x and y, you get one of amount x + y. When you split in x and y a ticket of total volume/weight x + y you get two tickets, of amount x and y.
The ticketer and payload are fixed and will never change once a ticket is created. The amount won’t change, but one might want to say it does. Language gets tricky very quickly (because linear type are complicated): the amount only changes when tickets are split or joined. Strictly speaking, you get new tickets, with same creator and payload, so you didn’t change the amount, rather, you transformed the tickets into new ones. In other words, you burned the ticket and created two new ones, keeping the original ticketer and payload information. Indeed, as mentioned previously, ticket are linear type values, therefore they are consumed when used.
The payload gives the ticket its type: a ticket with a payload of 1n is a nat ticket, and so is one with payload 2n. Payload type is not restricted to nat obviously, it can be any comparable type. The other two embedded pieces of information have a fixed type, respectively address and nat.
Comparable types in Michelson: types for which the COMPARE primitive is defined such as, basic types (int, string, bytes, but also address, signature, and more), and combinaisons thereof (option <comparable type> or pair ...).
Type vs Key
The generic type is 'a ticket where 'a refers to the type of the payload. But tickets of the same type can only be joined if they have the same ticketer and same payload value. Let’s take string ticket as an example. TICKET(bob, "toto", 42), TICKET(jim, "toto", 42) and TICKET(bob, "otto", 42) have the same type (string ticket) but are incompatible because they have different ticketers or different payloads.
- TICKET(bob, "toto", 42) + TICKET(bob, "toto", 69) -> TICKET(bob, "toto", 111)
- TICKET(jim, "toto", 42) + TICKET(bob, "toto", 69) -> ERROR
- TICKET(bob, "otto", 42) + TICKET(bob, "toto", 69) -> ERROR
Therefore, it’s useful to think in terms of “ticket key”": a key being a ticketer and a payload value. There are several keys of string ticket (string being the type of the payload): one for each value of the payload and each ticketer, e.g. we can have (bob, "toto") or (bob, "otto") or (jim, "toto") tickets. In essence, tickets of the same key can always be joined, and splitting a ticket produces 2 tickets of the same key as the original. However, tickets of the same type sometimes cannot be joined: they must have the same ticketer and payload value.
Please note that there is no way to change the ticketer nor the payload of a ticket: there simply exists no such primitive in the language. Since ticketer and payload are fixed at creation, ticket key cannot be modified.
As the ticketer never changes, even when splitting or joining, a contract has unambiguous control over tickets of all keys attributed to him; and everybody receiving a ticket knows where it came from originally. For example, to create a fungible token, one just has to write a minting contract, that creates tickets with a fixed payload. Those tickets can be sent to users, without loosing track of the total amount, and there is still no risk that people could forge them (i.e. create new tickets without the ticketer’s knowledge/consent).
Linear types: indistinguisable from magic?
The invariants come from and have consequences on the way they are manipulated. Writing code can get tricky, but in a strongly-typed language you can only write correct programs, this is the way.
Rather than diving into the philosophical intricacies of linear types, let’s examine some practical consequences.
Variables: use it and lose it
An easy way to see linear types, is that a variable can only be used once. Once it’s been used, it’s lost. For instance, the following expression cannot be typed:
It throws the following errors:
Warning: variable "ticket" cannot be used more than once.
Ill typed contract: type ticket nat cannot be used here because
it is not duplicable. Only duplicable types can be used with
the DUP instruction and as view inputs and outputs.
That means that any function that takes a ticket as an argument needs to return it otherwise it’s lost. The naive vision of read_ticket cannot work:
Otherwise, once read a ticket would be lost/burned forever.
The actual function is:
The only constructors, the only ways to obtain a new ticket, are create_ticket, split_ticket and join_tickets.
In LIGO, they have the following signatures
- create_ticket: returns a completely new ticket (please note that types can be infered, and only indicated here to emphasize the difference between type and key)
- split_ticket: transforms a ticket into new tickets of same key
- join_tickets: transforms two tickets of same key into a new ticket of that key
Finding in storage
If tickets are in a map, then it is not possible to find a ticket and leave it in the map. If you access it, you must delete it from the map. Use get_and_update, with None as new value, to obtain the searched value and the updated map in one fell swoop:
Updating in storage
While updating, be careful with get_and_update, if the new value is Some ticket the value returned for the ticket is None, otherwise, obvious duplication:
The value _ticket returned by the function is actually None.
Onchain views are a built in mechanism to provide read-only synchronous access to a smart contract. Views are a bit similar to endpoints, in that they take values of type parameter * storage, but rather than returning operation list * storage, views can return any value, but have no effect on the storage.
Parameters (view_parameter) and return values (view_return_value) can be of any type but big_map, sapling_state, operation, and ticket.
Comparison again: XTZ, tokens, tickets
Let’s reexamine the comparison between FA tokens and XTZ, and add tickets into the mix.
Storage of tickets can be decentralised, as tickets can be transferred between contracts. Moreover, creation is completely centralized, so it’s still possible to store all tickets of one key in one place: just don’t distribute them and hold a ledger.
Regarding trust, if we make the same distinction between ownership and value:
- ownership trust is simple, it’s only the contract storing the ticket that needs to be trusted,
- value trust depends on the usage of the tickets, but in any case it will down to the correctness of the contracts that make use of the ticket.
Gas OverHead again depends on the chosen architecture for a particular use case. The less centralized, the less gas overhead. There will be michelson executed to manipulate the tickets, so there will be gas consumption, but it can be way less. But again, will depend on implementation and architecture.
Limitation: As of May 2022, tickets can only be hold by originated addresses (a.k.a. smart contract addresses) but this limitation should be removed in a future proposal. Tezos layers 2 like TORUs, SCORUs, DEKU, or Chusai (coming soon) can already hold tickets for implicit accounts (tz1xx, tz2xx, tz4xx).
A ticket to Tezos Layer 2
When you take a look at Layer 2 ecosystems in several blockchains you are quickly stuck by having to bridge main chain tokens to several layer 2 tokens. Passing assets between L2s is even worse.
Having tickets as first class citizen of all Tezos Layer 2 provides a lot of simplicity and avoid lot of useless fees for locking/unlocking in the main chain. It allows all Layer 2 to be “token agnostic” and manipulate tickets regardless of their origin. As long as a token is implemented using tickets, it is supported by all Layer 2 implementing tickets, for free, without any dedicated infrastructure. If it’s not, a simple smart contract providing tickets for tokens (that it stores in a vault) is enough.
- You could have a smart contract that mint a cTez ticket for you, vaulting cTez in a contract storage against this ticket,
- You will be able to deposit this ticket to a TORU rollup,
- Then do a transaction of a specific amount,
- Then withdraw the remaining amount of the ticket to Tezos,
- Then deposit to DEKU Sidechain,
- Then use the ticket with a smart contract,
- Then withdraw to Tezos and finally burn your ticket from the initial ticketer against the remaining cTez.
You used in Tezos, Optimistic Rollup and Sidechain the same ticket!
Ways to think about tickets
To conclude, here are a few examples of (useful?) ways to think about tickets: to reason about the semantic of a contract, to experiment with new ways to implement existing functionality, to communicate concepts.
- An unforgeable and traceable piece of data
- Cash, it’s in your pocket but it’s not forgeable (heh)
- Keys to unlock functionalities
- A way for the blockchain to natively support any custom tokens
- Tokens working as intended
- A synchronization artefact to deal with concurrency
- A tool to write data in the global ledger: the place it’s stored in doesn’t change the meaning/value of the ticket
- Data that can be reasoned on at the level of the whole chain, not merely at the level of the contract
- A generic Smart Contract data type with very strong invariants