Skip to main content

Examples of contracts

In this chapter, we will present the structure of simplified versions of a number of classical smart contracts:

The goal is for you to get a good understanding of what a smart contract is, how the storage and entry points are used. We will also introduce how and why contracts interact with each other.

Finally, this will give you an overview of what some of the most common smart contract may look like.

danger

The contracts below are simplified contracts, provided for educational purposes only. They are not meant to be implemented and used as is, as some of them may contain potential flaws.

FA1.2 - Fungible token

The goal of this contract is to create and manage a single fungible token.

It implements the FA1.2 standard, which makes it compatible with wallets, decentralized exchanges and other tools.

It only supports a small number of features:

  • Each user can own a certain number of tokens
  • Users can transfer tokens to other users
  • Users can allow another contract, for example a decentralized exchange, to transfer some amount of their tokens for them.

The contract contains two main entry points:

  • transfer, to transfer a number of tokens from one address to another
  • approve, for a caller to indicate that they allow another address to transfer a number of their tokens

To be compatible with FA1.2, and so that other contacts can access to information, it also contains three entry points that have no effect other than sending information back to the caller:

  • getBalance sends the number of tokens owned by a given address
  • getAllowance sends the amount of tokens belonging to a certain address that another address is allowed to transfer for them
  • getTotalSupply sends the total amount of tokens managed by this contract
StorageEntry points effects
  • totalsupply: nat

  • ledger: big-map
    Key:
    • holder: address
    Value:
    • tokens: nat

  • allowance: big-map
    Key:
    • owner: address
    • spender: address
    Value:
    • amount: nat

  • transfer(from: address, to: address, value:nat)
    • Check that ledger[from].tokensvalue
    • If the caller address is not from
      • Check that allowance[from, caller] exists, with amountvalue
      • Substract value from allowance[from, caller].amount
      • If allowance[from, caller].amount = 0, delete allowance[from, caller]
    • Create entry ledger[to] with 0 tokens, if it doesn't exist.
    • Substract value from ledger[from].tokens
    • Add value to ledger[to].tokens

  • approve(sender: address, value: nat)
    • Create entry allowance[caller, sender] with amount 0, if it doesn't exist.
    • Add value to allowance[caller, sender].amount

  • getBalance(owner: address, callback: contract)
    • If ledger[owner] exists, set ownerBalance to ledger[owner].tokens
    • Otherwise, set ownerBalance to 0
    • Call callback(ownerBalance)

  • getAllowance(owner: address, spender: address, callback: contract)
    • If allowance[owner, spender] exists, set amount to allowance[owner, spender]
    • Otherwise, set amount to 0
    • Call callback(amount)

  • getTotalSupply(callback: contract)
    • Call callback(totasupply)

FA2 - NFTs: Non-Fungible Tokens

The FA2 standard specifies contracts that can be of different types:

  • Single fungible token
  • Multiple fungible tokens
  • Non fungible tokens (NFTs)

Implementing the FA2 standard allows the contract to be compatible with wallets, explorers, marketplaces, etc.

Here, we will present an implementation for NFTs. The entry points for the other types are the same, but the implementation differs.

FA2 contracts must have the following entry points:

  • transfer can be called either by the owner of tokens to be transferred, or by an operator allowed to do so on their behalf.
    It takes a list of transfers of different tokens from the owner, to different addresses.
  • update_operator can be called by the owner of tokens to add or remove operators allowed to perform transfers for them.
    It takes a list of variants, each consisting of either adding or removing an operator for a given token.
  • balance_of is used to access the balance of a user for a given token.

FA2 supports a number of optional entry points to access information, but we won't provide them here.

StorageEntry points effects
  • ledger: big-map
    Key:
    • token_id: nat
    Value:
    • owner: address
    • metadata: string

  • operators: big-map
    Key:
    • owner: address
    • operator: address
    • token_id: nat
    Value:
    • nothing

  • transfer(from: address, transfers: list of [to: address, token_id: nat, amount: nat])
    • For each item [to, token_id, amount] in transfers
      • Check that amount is 1
      • Check that caller is from, or that operators[from, caller, token_id] exists
      • Check that ledger[token_id].owner is from
      • Set ledger[token_id].owner to to

  • update_operator(updates: list of [type: variant, owner: address, operator: address, token_id: nat])
    • For each item in updates of type add_operator:
      • Check that owner is the caller
      • Create entry operators[owner, operator, token_id] if it doesn't exist.
    • For each item in updates of type remove_operator:
      • Check that owner is the caller
      • Delete entry operators[owner, operator, token_id] if it exists.

  • balance_of(requests: list of [owner: address, token_id: nat], callback: address)
    • Create list results of [owner: address, token_id: nat, balance: nat]
    • For each request in requests:
      • If ledger[token_id].owner is owner, set balance to 1
      • Otherwise, set balance to 0
      • Add [owner, token_id, balance] to results
    • Call callback(results)

NFT Marketplace

The goal of this contract is to manage sales of NFTs from one address to another. It pays a share of the selling price to the admin of the marketplace, in exchange for providing a dApp that facilitates finding and purchasing NFTs.

It provides the following entry points:

  • add is called by a seller who puts their one of their NFTs on sale for a given price.
    The seller must indicate which FA2 contract holds the NFT, and what the id of the NFT is within that contract.
    It requires for the marketplace to have been set as an operator in the FA2 contract, for this token.
  • remove can be called by a seller to remove their NFT from the marketplace, if it hasn't been sold.
  • buy is to be called by a buyer who pays the set price to buy a given NFT.
    The admin account of the marketplace receives a share of the selling price.
StorageEntry points effects
  • admin: address

  • fee_rate: nat

  • tokens: big-map
    Key:
    • contract: address
    • token_id: nat
    Value:
    • seller: address
    • price: tez

  • add(token_contract: address, token_id: nat, price: tez)
    • Transfer token ownership to the marketplace:
      call token_contract.transfer(caller, [self, token_id, 1])
    • Add entry [token_contract, token_id] to tokens with values [caller, price]

  • remove(token_contract: address, token_id: nat)
    • Check that caller is the seller of the token:
      check that tokens[token_contract, token_id].seller is caller
    • Transfer token ownership back to seller:
      call token_contract.transfer(self, [caller, token_id, 1])
    • Delete entry [token_contract, token_id] from tokens

  • buy(token_contract: address, token_id: nat)
    • Check that the token is on sale for the amount paid by the caller:
      check that tokens[token_contract, token_id].amount = amount_paid
    • Transfer token ownership to the caller:
      call token_contract.transfer(self, [caller, token_id, 1])
    • Set admin_fee = fee_rate * amount_paid / 100
    • Create transaction to send admin_fee to admin
    • Create transaction to send amount_paid - admin_fee to [token_contract, token_id].seller
    • Delete entry [token_contract, token_id] from tokens

Escrow

An escrow is a contract that temporarily holds funds in reserve, for example tokens paid by a buyer of a service, while their request is being processed.

Its goal is to provide trust between the parties of a transaction that can't be atomic:

  • the buyer can't send the payment to the service until the request has been fulfilled.
  • the service can't start working on the request without a guarantee that it will get paid.

There are a number of different types of escrow contracts. In our contract, the service to be provided is some data that needs to be sent by the service, where the escrow contract has the ability to verify the validity of the data.

For example, the request could consist in the service sending the decrypted version of some encrypted data.

Our contract has three entry points:

  • send_request creates a new request with a deadline and collects the payment, that will be held in the escrow.
    Along with the data, the request contains the code that will verify the validity of the answer (a lambda)
  • fulfill_request is to be called later by the service.
    It verifies that the request has been performed and transfers the funds to the service.
  • cancel_request can be called buy the buyer if the request had not been fulfilled after the deadline.
    It transfers the funds back to them.
StorageEntry points effects
  • requests: big-map
    Key:
    • owner: address
    • id: nat
    Value:
    • amount: tez
    • service: contract
    • data: bytes
    • verification: lambda
    • deadline: datetime
    • answer: option<bytes>
  • send_request(id, service, data, verification, deadline)
    • Check that requests[caller, id] doesn't exist
    • Create requests[caller,id] entry with amount_paid and all the parameters
    • Create a call to service(data, self, amount, deadline)

  • fulfill_request(owner, id, answer)
    • Set request = requests[owner, id], checking that it exists
    • Check that verification(request.data, answer) returns true
    • Set requests[owner, id].answer to answer
    • Create transaction to send request.amount to caller

  • cancel_request(id)
    • Set request = requests[caller,id], checking that it exists
    • Check that request.answer is none, meaning the request hasn't been processed
    • Check that the deadline has expired: now > request.deadline
    • Create transaction to send request.amount to caller
    • Delete requests[caller,id] entry

DAO: Decentralized Autonomous Organization

A DAO is a contract that represents an entity composed of a number of participants. It provides a way for these participants to collectively take decisions, for example on how to use tokens held in the balance of the DAO contract.

There can be all kinds of DAOs, and we will present a simple but powerful version.

Our DAO stores the addresses of all its members, a list of all the proposals, and keeps track of who voted for them.

It has the following entry points :

  • propose can be called by any member to make a new proposal, in the form of a piece of code to execute (a lambda).
  • vote can be called by any member to vote in favor of the request.
    When the majority of members voted in favor, the proposal is executed.
  • add_member adds a new member to the DAO.
    It may only be called by the DAO itself, which means the call has to go through a proposal and be voted on.
  • remove_member removes a member from the DAO.
    It may only be called by the DAO itself.

When deployed, an initial list of members needs to be put in the storage, and typically some tez put in the balance.

StorageEntry points effects
  • nb_members: nat

  • members: big-map
    Key:
    • user: address
    Value:
    • nothing

  • requests: big-map
    Key:
    • id: nat
    Value:
    • action: lambda
    • deadline: datetime
    • nb_votes: nat

  • votes: big-map
    Key:
    • request_id: nat
    • user: address
    Value:
    • nothing
  • propose(id, action, deadline)
    • Check that requests[id] doesn't exist
    • Check that the caller is a member: members[caller] exists
    • Create requests[id] entry with values of action and deadline, and with nb_votes set to 0

  • vote(id)
    • Check that the caller is a member: members[caller] exists
    • Check that the deadline is not passed: now > requests[id].deadline
    • Check that the caller has not voted: votes[id, caller] doensn't exist
    • Record vote: create votes[id, caller] entry
    • Increment requests[id].nb_votes
    • If we have a majority of votes: requests[id].nb_votes * 2 > nb_members
      • Excecute requests[id].lambda, and the transactions it returns.
      • Delete entry requests[id]

  • add_member(user)
    • Check that the caller is the DAO itself: caller == self
    • Create entry members[user], checking that it doesn't already exist.
    • Increment nb_members

  • remove_member(user)
    • Check that caller is the DAO itself: caller == self
    • Delete entry members[user], checking that it exists.
    • Decrement nb_members

DeFi: Flash loan

A Flash loan is one of the many tools of decentralized Finance (deFi).

It provides a way for a user to temporarily get access to large amounts of tez, without any collateral. This allows them to take advantage of opportunities and make a profit, without the need to have their own funds.

The idea is that the following steps will be done in an atomic way:

  • the borrower receives the requested amount from the contract
  • the borrower uses the amount in a series of calls to other contracts, that allow him to make some instant profit
  • the borrower then pays the requested amount plus some interest to the contract, and gets the rest of the profit.

The key aspect to understand is that all this is done atomically, which means that if any of these step fails, and if for example the borrower is not able to pay back the borrowed amount plus interest, then the whole sequence is canceled, as if it never happens. There is no risk at all for the lender contract.

This contract can even lend the same tez to multiple different people within the same block, as each loan is paid back immediately, so the tokens can be used again for another loan.

One may use a flash loan to take advantage of an arbitrage situation, for example if two different exchanges offer to buy/sell the same type of tokens, at a different price from each other. The user can buy tokens from one exchange at a low price, then sell it to the other exchange at a higher price, making a profit.

Our contract has three entry points:

  • borrow is called by the borrower, indicating how many tez they need.
    The amount is transferred to the caller, then a callback he provided is executed. At the end of this entry point, we verify that this callback has repaid the loan.
  • repay is to be called by this callback once the actions that generate a profit are done. The call should come with payment of the borrowed amount, plus interest.
  • check_repaid is called by the borrow entry point after the call to the callback. Indeed, borrow can't do the verification itself, since the execution of the callback is done after all the code of the entry point is executed.
StorageEntry points effects
  • admin: address

  • interest_rate: nat

  • in_progress: boolean

  • loan_amount: tez

  • repaid: booean
  • borrow(loan_amount: tez, callback: address)
    • Check that in_progress is false
    • Set in_progress to true
    • Transfer loan_amount to caller
    • Set storage loan_amount to loan_amount
    • Set repaid to false
    • Create call to callback
    • Create call to check_repaid

  • repay()
    • Check that in_progress is true
    • Check that paid_amount is more than loan_amount + interest
    • Set repaid to true

  • check_repaid()
    • Check that repaid is true

  • collect(nbTez)
    • Check that caller is admin
    • Transfer nbTez to admin