To follow this tutorial, create a new repo. The full correction code can be found in the contracts_final
folder.
Transfer crypto currency throught interac e-transfer process, using a secret question. You send tokens to someone and lock them with a couple of question/answer. The declared receiver can then claim the tokens by providing the answer, or you can redeem the tokens.
WebApp : Using TypeScript (and your favorite framework) Contract : Using Ligo
- What tools to use: Ligo, Taqueria, Taquito, and
octez-client
- How to create a starter repository
- How to initialize a contract
Install octez-client
You can download the octez-client
binary either directly from the Tezos repo or using a package manager.
Install Node.js and npm
Useful to develop your webapp, also used by Taqueria.
Install Ligo
Ligo is a high-level strongly typed programming language that allows writing contracts for the Tezos blockchain, testing them, and compiling them to Michelson, the low-level language that executes on the Tezos blockchain.
Install Taqueria
Taqueria improves development experience by managing the development lifecycle.
We recommend using Ligo vscode plugin.
taq init
A taq'ified folder contains the following subfolders:
.taq
contains Taqueria configuration files are;artifacts
is where your compiled Michelson will be generated;contracts
is where your contracts sources have to be.
To plug taqueria with ligo let's install a plugin :
taq install @taqueria/plugin-ligo
The Ligo plugin for Taqueria provides tasks to work with Ligo smart contracts such as compiling and testing. Run
taq create contract intezos.jsligo
It should generate a file in contracts named intezos.jsligo
filled with a template for a smart contract counter where 3 action are available :
Increase
,Decrease
,Reset
.
🚧 Taqueria is not up to date and this provided template is obsolete. The new one will be the code below, that should should copy paste into your intezos.jsligo
file. 🚧
/*
A type storage, representing stored data in you SC(smart contract)
*/
type storage = int;
/*
An entry-point is a pure function that can be formalized as :
[list<operation>, storage] = f(parameter, storage)
where :
- f is the entry-point function that is called
- parameter contains the inputs that are sent by the caller when the contract is called
- storage is the state of the storage. The input is the current state of the storage, and it outputs the next state
- list<operation> is a list of commands that will be executed by the block chain (for instance transfers…)
Entrypoint are tag with the keyword @entry
*/
@entry
const increment = (delta : int, store : storage) : [list<operation>, storage] =>
// Empty list mean there is no other invokation, the second element is the new state of the storage
[list([]), store + delta];
@entry
const decrement = (delta : int, store : storage) : [list<operation>, storage] =>
[list([]), store - delta];
/*
The unit type in Michelson or LIGO is a predefined type that contains only one value that carries no information. It is used when no relevant information is required or produced.
Also _ which can be used as prefix of variable name like on _parameter, explain to compiler than this value is not used later.
*/
@entry
const reset = (_parameter : unit, _storage : storage) : [list<operation>, storage] =>
[list([]), 0];
Let's focus a bit on unit
to bring a key notion of JSLigo and smart contract.
In JsLIGO, the unique value of the unit
type is []
.
It can be used similarly to the void/null types from other languages (e.g. as the return type of a function that does not return anything), but has a slightly different interpretation: it is considered as a defined value.
A smart contract is a pure state machine without side effect. Possible state transitions are represented by functions called entrypoints that take in the current state along with some parameters, and return a new state. These transitions can then be applied (by anyone, unless specific precautions are taken) to change the state of the contract on the blockchain.
sequenceDiagram
Note left of Wallet tzxxx : Wallet are addresses prefixed by tz
Wallet tzxxx->>Tezos chain: Call smart contract identified by address(KTxxx), applying the chosen entrypoint (state-modifying function) with the given parameters (Michelson expression)
Tezos chain->>Tezos chain: Retrieve current storage state
Tezos chain->>SmartContract KTxxx: Provide initial storage, entrypoint to apply, and parameters to pass to it
SmartContract KTxxx->>SmartContract KTxxx: Calculate new state
SmartContract KTxxx->>Tezos chain: Return a list of operation and the new content of the storage
Tezos chain->>Tezos chain: Mutate storage state
Note right of Tezos chain: This process repeats for each operation in the returned list of operations
There are two main uses of unit
:
- an entrypoint whose parameter type is
unit
is simply an entrypoint that does not take any argument; and - a contract with storage type
unit
is a contract that does not store anything.
- In Ligo, you will learn how to :
- Manage your storage, entrypoint and parameters
- Use assertions in code
- Execute an operation (namely transfer XTZ to a wallet)
<>
operator for genericity- Use built-in function
- What is
balance
andsource
of a contract - What is
sender
andamount
of an operation - Write and execute a test
- Split your code
- With taqueria, you will learn how to:
- Deploy a contract through Taqueria
- Simulate an execution
- On tezos, you will learn how to:
- Import key generated by Taqueria onto
octez-client
- Invoke contract with
octez-client
- Use explorers
- Import key generated by Taqueria onto
Originate a contract containing X amount of XTZ which can be claimed or redeemed, secured with question and answer.
- Claim: If you are the declared receiver you can ask your XTZ by invoking claim endpoint with the passphrase as parameter
- Redeem: If you are the originator (creator of the SC) and the amount has not been claimed, you can redeem it.
We will start Intezos from scratch so remove the generated template in intezos.jsligo
.
We will now define the storage type of our contract. Storage is the data stored in your smart-contract, on the blockchain. When deploying your contract, you will pay for the space required by the storage, so keep it as light as possible and only use it only for data that profits from being on the blockchain.
type storage = {
amount: tez, // The transferred amount (xtz)
sender: address, // Address of the sender (tzxxx)
receiver: address, // Address of the recipient (tzxxx)
question: string, // Question that the receiver needs to answer
encrypted_answer: string, // The expected answer
pending: bool // Can the funds still be claimed or redeemed?
};
As you can see storage is defined through a type. for better comprehension it's possible to group properties into another type
type secret = {
question: string, // Question that the receiver needs to answer
encrypted_answer: string // The expected answer
};
type storage = {
amount: tez, // The transfered amount (xtz)
sender: address, // Address of the sender (tzxxx)
receiver: address, // Address of the recipient (tzxxx)
secret: secret, // Type secret
pending: bool // Can the funds still be claimed or redeemed?
};
Now that the storage is defined, we want to interact with it by executing some invokable code on the blockchain.
Let's create a first entrypoint that switches the pending
value to false
when claim
has been invoked :
@entry
const claim = (_: unit, store: storage): [list<operation>, storage] => {
return [list([]), {...store, pending: false}]
};
We use an annotation @entry
to declare the method as a contract entrypoint, and it has two arguments:
- The first argument represents the parameters passed to the entrypoint,
_ : unit
here because we don't manage any parameter for now. - The second argument
store : storage
is the state of the storage before execution of the script. This entrypoint returns a pair containing: - an empty list of operation
list([]) : list<operation>
; and - a copy of the current state
store
with thepending
field set tofalse
.
Because the compiler can't infere list([]) as list alone.
Note: The compiler can not infer what type the empty list has, and we therefore have to help it, either by specifying the return type of the entrypoint [list<operation>, storage]
as done above, or by using list([]) as list<operation>
in place of list([])
.
When you compile using the ligo
command, it'll produce a corresponding Michelson (langage understood by the Tezos blockchain) program on the standard output:
ligo compile contract contracts/intezos.jsligo
To save the produced code, use -o
flag
ligo compile contract contracts/intezos.jsligo -o artifacts/intezos.tz
Now you can open the artifacts/intezos.tz
file and see the produced Michelson is.
It is also possible to use Taqueria :
taq compile intezos.jsligo
To simulate your contract, you need to initialize the context (here, the storage) and prepare call simulation, with an entrypoint and its parameters.
With ligo run dry-run
we will provide an input state (storage), an action to perform (entrypoint and parameters). Then the Ligo compiler, using a Michelson-interpreter, will generate the output state.
ligo run dry-run contracts/intezos.jsligo 'unit' '{ amount: 100000 as mutez, receiver: "tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq" as address, secret: { question: "Do you like Paul", encrypted_answer: "yes he is awesome but encrypted" }, sender: "tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq" as address, pending: true }' --entry-point claim
Will generate an output state :
( LIST_EMPTY() ,
record[amount -> 100000mutez ,
pending -> False(unit) ,
receiver -> @"tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq" ,
secret -> record[encrypted_answer -> "yes he is awesome but encrypted" ,
question -> "Do you like Paul"] ,
sender -> @"tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq"] )
Where :
LIST_EMPTY()
is a list of operations to apply (here empty because we returnlist([])
)record
is the new state of your storage.
As you can see, in the command, the state of initial storage is pending: true
, and in the output, it has been changed to pending -> False(unit)
as expected.
Using ligo run dry-run
like this is a bad user experience :
- the command is not readable;
- editing the storage (or parameters) is painful;
- you need to type the command in one line; and
- to re-execute a dry-run you have to find the good command in your bash history.
Let's do some software engineering to have a better way to run dry-run.
Create a new file intezos.runner.jsligo
it will be a wrapper around your test used to declare some tests data.
// Start by integrating intezos.jsligo into your runner
#include "./intezos.jsligo"
// Then define a constant corresponding to your initial storage
const default_storage: storage =
{
amount: 100000 as mutez,
receiver: "tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq" as address,
secret: {
question: "Do you like Paul",
encrypted_answer: "yes he is awesome but encrypted"
},
sender: "tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq" as address,
pending: true
};
Now you can trigger your dry-run like this :
ligo run dry-run contracts/intezos.runner.jsligo 'Claim(unit)' 'default_storage'
Now we want to implement the condition. Rules to process the transfer :
- The state is pending
- The operation emitter is the identified receiver
- The operation contain an arg with the answer which have to be the same as the one stored. Expressed with if statement :
/* To be clean, create a type claim_parameter which contain the string */
type claim_parameter = { answer: string };
/* And pass it as parameter (first argument) */
@entry
const claim = (parameter: claim_parameter, store: storage): [list<operation>, storage] => {
if(store.pending && Tezos.get_source() == store.receiver &&
parameter.answer == store.secret.encrypted_answer)
{
// ...store is the spread operator like in typescript
// that's mean in { ...store, pending: false } all properties of the record store, but we replace pending with the value false.
return [list([]), { ...store, pending: false }]
}else
{
return [list([]), store]
}
};
Edit the intezos.runner.jsligo
file
const claim_default_parameter: claim_parameter =
{
answer: "yes he is awesome but encrypted"
};
const claim_bad_answer_parameter: claim_parameter =
{ answer: "no" };
Then you can dry-run :
ligo run dry-run contracts/intezos.runner.jsligo 'Claim(claim_default_parameter)' 'default_storage'
Oh, the pending
value is true
, so the condition is evaluate to false
!
Yes because you are on dry-run, so the context provided by the blockchain doesn't exist
Tezos.get_source()
return arbitrary contract.
There is 4 values provided by tezos :
amount
: the amount provided by the transactionbalance
: the amount owned by the contractsender
: the address which trigger the transaction (can be a smart contract, addres of smart contract are prefixed byKT
)source
: the wallet who originate the contract (can only be a wallet, address of wallet are prefixed bytz
)
Now we want to mock source value, to do it you can use the flag --source
ligo run dry-run contracts/intezos.runner.jsligo 'Claim(claim_default_parameter)' 'default_storage' --source 'tz1Yj4FviaKEy6ER8ZDeiH2w2Lx8bapjuJEq'
Now you can see the state mutation !
That's a first step but we can improve it with assert
statement, the code is elegant and less expensive, for deployer and customer and can fail during the simulation instead of execution :
@entry
const claim = (parameter: claim_parameter, store: storage): [list<operation>, storage] => {
assert(
store.pending && Tezos.get_source() == store.receiver &&
parameter.answer == store.secret.encrypted_answer
);
return [list([]), { ...store, pending: false }]
};
You can test it with dry-run
learned before !
Because you are on a blockchain, your storage is visible by everyone. To hide it let's use the magic of Encryption, we gonna use the core library Crypto
Let's create the function encrypt
/*
Crypto.sha256 is typed as follow let sha256: (b: bytes) => bytes
So you need to transform your string into byte, and also edit your storage encrypted_answer type with bytes
*/
type secret = {
question: string,
encrypted_answer: bytes
};
const encrypt = (value) => Crypto.sha256(Bytes.pack(value));
Now you can use it into your assert condition
assert(
store.pending && Tezos.get_source() == store.receiver &&
encrypt(parameter.answer) == store.secret.encrypted_answer
);
Don't forget to edit the default_storage
to be able to dry-run
:
encrypted_answer: Crypto.sha256(Bytes.pack("yes he is awesome but encrypted")),
Congrats, the secret is now offuscated.
Like mentioned before, entries are a pure state machine without side effect so by returning the new status of the storage, your smart contract describe a new state but didn't mutate it, that's the role of the chain.
That's the same things for operations. In output you can describe another operation which have to be executed by the chain but didn't invoke it directly, orchestration is done by the chain.
So to do a transfer, we need to describe and return a new transaction.
First thing is to find the wallet on the chain. In tezos, a wallet is a special contract so to find it, you can search the contract defined by the address with Tezos.get_contract_opt
.
Defined as :
let get_contract_opt : (a: address) => option<contract<'param>>
As you can see it's suffixed by _opt (option). It's the Some None pattern.
Some and None are two Variant which can be processed using pattern matching a common pattern in functionnal programming.
/*
Find the wallet from the address and if the address exist return it.
If not interrupt the execution of the smart-contract.
*/
const receiverAddress = match
(Tezos.get_contract_opt(store.receiver))
{
when(Some(contract)): contract
when(None()): failwith("Not an existing address")
};
/* Create operation describing the transfer of the amount into the wallet
Like explained in doc, To indicate an account, use unit as first parameter.
*/
const transferOperation: operation =
Tezos.transaction(unit, store.amount, receiverAddress);
/* And return the operation which gonna be executed by the chain, with the mutation of the store */
return [list([transferOperation]), { ...store, pending: false }]
We will need to find a wallet for redeem entry to, let's factorize it a bit using generic :
/* To define a generic function use diamond operator '<>' */
const get_instanced_address_or_fail = <t>(address) =>
match
/* To help the compiler here, you need to declare `as option<contract<t>>` */
(
Tezos.get_contract_opt(address) as option<contract<t>>
)
{
when(Some(contract)): contract
when(None()): (failwith("Not an existing address"))
};
Now you can simplify claim entrypoint with :
const transferOperation: operation =
Tezos.transaction(unit, store.amount, get_instanced_address(store.receiver));
return [list([transferOperation]), { ...store, pending: false }]
So now you can use get_instanced_address_or_fail
to get a wallet or a contract !
If you dry-run
you'll see something like :
( CONS(Operation(0135a1ec49145785df89178dcb6e96c9a9e1e71e0a00000001a08d0600008f8d059db9a174e7fadb94687fefa70551ee8adf00) ,
LIST_EMPTY())
Michelson representation of an operation !
To test our contract through blockchain, we gonna deploy it on Ghostnet testnet
Using Taqueria to compile the code and the initial storage. Now taqueria use the file intezos.storageList.jsligo
to compile storage, so copy and paste storage declaration from intezos.runner.jsligo
into intezos.storageList.jsligo
and run :
taq compile intezos.jsligo
Before to deploy it, you'll need to install taqueria plugins : Run :
taq install @taqueria/plugin-taquito
Then you will be able to deploy
taq deploy intezos.tz --mutez 100000 --storage intezos.default_storage.tz --env testing
Where :
intezos.tz
is the compiled smart contract--mutez 100000
is the number of mutez transmit to the contract (same as the amount in the storage)--storage intezos.default_storage.tz
is the compiled initial storage to use--env testing
defined in.taq/config.json
isghostnet
After execution :
A keypair with public key hash tz1PMrwFepZWiJgoFrdJe1F4ob7erngsPEPp was generated for you.
To fund this account:
1. Go to https://teztnets.xyz and click "Faucet" of the target testnet
2. Copy and paste the above key into the wallet address field
3. Request some Tez (Note that you might need to wait for a few seconds for the network to register the funds)
No operations performed
Taqueria has created a wallet for you, you can find the definition in .taq/config.local.testing.json
. But your wallet is empty, fund it following instruction and retry :
taq deploy intezos.tz --mutez 100000 --storage intezos.default_storage.tz --env testing
Your smart contract should be deployed ! Find the address on the output of the command and go see it on
https://ghostnet.tzkt.io/<CONTRACT_ADDRESS_KT1>
Now the smart contract is deployed invoke we gonna use octez-client
Begin by importing the wallet generated by taqueria
in the octez-client
using your private key stored in .taq/config.local.testing.json
octez-client import secret key taq_deployer unencrypted:<PRIVATEKEY>
Your endpoint need parameter, the parameter have to be valid michelson. You can use to compile your parameter
ligo compile parameter contracts/intezos.jsligo 'Claim({answer: "yes he is awesome but encrypted" })'
Result is a string, because a string value alone is a string in michelson, but if your parameter is complex, you should use the command
Then call the endpoint
octez-client --endpoint https://ghostnet.tezos.marigold.dev call <CONTRACT_ADDRESS_KT1> from taq_deployer --entrypoint claim --arg '"yes he is awesome but encrypted"'
Where :
--endpoint xxx
Is the RPC endpoint you will deal with. Marigold is a stable one<CONTRACT_ADDRESS_KT1>
Is the address of your deployed contractfrom taq_deployer
Is the alias of the wallet imported at precendent step--entrypoint
is your contract entrypoin to invoke--arg
is the parameter pass to your entrypoint
Now check on https://ghostnet.tzkt.io/<CONTRACT_ADDRESS_KT1>/operations/
you should see the operation !
You can also try with a failure :
octez-client --endpoint https://ghostnet.tezos.marigold.dev call <CONTRACT_ADDRESS_KT1> from taq_deployer --entrypoint claim --arg '"no"'
Note that the command fail at simulation step, so this invokation doesn't consume fees
You can use what you learned to implement redeem
entrypoint. It can be redeem if the emitter of the transaction is the sender, and it's not already done.
Don't forget to return the operation and new storage.
Congrats ! The next step will be to refactor the contract to make it reusable !