How to write Sapling contracts to transfer fungible tokens anonymously
Use Sapling on Tezos for anonymous transactions of fungible FA1.2 and FA2 tokens
This article is the continuation of this article about general Sapling contracts in Ligo.
Sapling is a feature added to the Tezos blockchain during the Edo upgrade in February 2021. It allows anonymous transactions of tokens within a smart contract.
Although this feature is mainly used to transfer XTZ tokens as demonstrated in the previous article, it is also possible to use it to transfer other kinds of fungible tokens that live on Tezos.
This article will show you how to transfer FA1.2 and FA2 tokens anonymously using the Sapling feature. Even if these two standards are different, the changes you have to make to a Ligo contract to accommodate them are pretty similar.
Note: this article requires a good knowledge of the Ligo language. You should be comfortable with the CameLigo syntax and with the different concepts of the language.
A refresher about Sapling
There is a whole section in the previous tutorial about Sapling, but here is a short summary to refresh your memory 🙂
Sapling is a feature of the Tezos blockchain introduced in the Edo upgrade in February 2021 to provide a simple way to exchange tokens anonymously.
A contract that implements Sapling provides an obfuscated pool where transactions between Sapling addresses happen without any possible means of linking the Sapling addresses to the corresponding Tezos addresses.
Because the balances tracked in the Sapling pool are represented by integers, it is possible to track any fungible tokens, as this tutorial illustrates.
Sapling contracts for FA1.2 tokens
Parameter and storage
The main difference in the types for the storage and the parameter between an XTZ Sapling contract and an FA1.2/FA2 Sapling contract will be in the storage:
type storage = {
state: 8 sapling_state;
fa1_2_contract: address;
}
type parameter = 8 sapling_transaction list
type fa1_2_parameter =
[@layout:comb]
{
from: address;
[@annot to] to_: address;
value: nat;
}
As you can see, the parameter
stays the same, but the storage
now includes the address of the FA1.2 contract you will target.
The only entrypoint of the FA1.2 contract that the Sapling contract needs to call is %transfer
. For convenience and to avoid accidental confusion between the arguments of this entrypoint, we define a record type fa1_2_parameter
corresponding to the type of the parameter of the %transfer
entrypoint in the FA1.2 standard.
Creating the contract reference
For gas efficiency purposes, you want to create a reference to the FA1.2 contract to which/from which you will send transfer transactions. Before sending a transaction from one contract to another contract, you must create a reference to the recipient contract and verify it exists with the expected entrypoint and the expected parameter type. This consumes a lot of gas and is better to be done only once.
Here is how it goes:
match ((Tezos.get_entrypoint_opt "%transfer" s.fa1_2_contract): fa1_2_parameter contract option) with
| None -> failwith "INVALID_FA1_2_CONTRACT"
| Some contract -> ...
You use Tezos.get_entrypoint_opt
to get the reference to the transfer
entrypoint of the contract and you pass the name of the entrypoint and the address of the FA1.2 contract.
This will return an optional value that you can then pattern match to get the reference to the contract that you can use in subsequent transfer operations to the contract.
Unshielding transactions
Unshielding transactions are one of the 3 types of transactions sent to a Sapling contract. They involve a transfer of tokens from the contract to a recipient.
You will know that a Sapling transaction is an unshielding transaction because the transaction balance returned by Tezos.sapling_verify_update
is strictly positive (i.e. equal to or greater than 1).
If that’s the case, here is what happens:
if tx_balance > 0
then
(
match (Bytes.unpack bound_data: key_hash option) with
| None -> failwith "UNABLE_TO_UNPACK_RECIPIENT"
| Some (recipient_key_hash) ->
let recipient =
recipient_key_hash
|> Tezos.implicit_account
|> Tezos.address
in
let param =
{
from = Tezos.get_self_address ();
to_ = recipient;
value = (abs tx_balance)
}
in
((Tezos.transaction param 0tez contract) :: ops),
{ storage with state = new_sapling_state }
)
First, you unpack the bytes of the bound data to retrieve the recipient’s address.
Once you have the address, it will be of type key_hash
but you need a value of type address
so you can convert it first to a contract
and then an address
using the piping feature of CameLigo.
Now, you can construct the parameter for the transfer
operation, a record with the following properties:
from
: the address of the sender (here, the Sapling contract)to_
: the address of the recipient of the tokensvalue
: the transaction balance innat
After creating the parameter, you can use the contract reference created earlier to forge a transaction to the FA1.2 contract and return it in the operation list
along with the updated Sapling state.
Shielding transactions
Shielding transactions are transactions that happen when a user sends tokens to the Sapling pool in the contract.
You will have to check first that there is no recipient address in the bound data, before checking that the transaction balance is strictly negative (i.e. less than zero):
match (Bytes.unpack bound_data: key_hash option) with
| Some (_) -> failwith "UNEXPECTED_RECIPIENT"
| None ->
if tx_balance < 0
then
let param =
{
from = Tezos.get_sender ();
to_ = Tezos.get_self_address ();
value = (abs tx_balance)
}
in
((Tezos.transaction param 0tez contract) :: ops),
{ storage with state = new_sapling_state }
else
...
After that, you can construct the parameter to be sent to the FA1.2 contract as a record with the following 3 properties:
from
: the address of the senderto_
: the address of the contractvalue
: the transaction balance innat
Finally, you can forge the operation to the FA1.2 contract to transfer the tokens from the sender’s account to the contract’s account. And don’t forget to return the new Sapling state as well!
Sapling transfers
Sapling transfers are probably the easiest kind of transaction, as Tezos.sapling_verify_update
will do all the work for you 😅
If the transaction balance is equal to zero, you can just return the new Sapling state:
if tx_balance < 0
then
...
else
ops, { storage with state = new_sapling_state }
Sapling contracts for FA2 tokens
Writing the contract for FA2 tokens will be very similar to the previous step, with one key difference: FA2 contracts allow the batching of multiple operations into one single transaction, so you will leverage this feature in your code.
Parameter and storage
Let’s look at the code first:
type storage = {
state: 8 sapling_state;
fa2_contract: address;
token_id: nat;
}
type parameter = 8 sapling_transaction list
type fa2_transaction =
[@layout:comb]
{
to_: address;
token_id: nat;
amount: nat;
}
type fa2_transfer =
[@layout:comb]
{
from_: address;
txs: fa2_transaction list;
}
type fa2_parameter = fa2_transfer list
type loop_els = ((address * nat) list * nat * storage) * 8 sapling_transaction
The storage includes a Sapling state and a contract address too, but this time, you need to include the id of the token that will be transferred.
The parameter is still a list of Sapling transactions and for convenience and type safety, you will also include the type for the parameter of the transfer
entrypoint of the FA2 contract.
The last type, loop_els
, is just for convenience, to make the function used to fold the list of Sapling transactions more readable.
Unshielding transactions
You will recognize unshielding transactions by the same property: when the transaction balance is strictly positive.
You will declare an empty list of elements of type address * nat
in the accumulator of the main folding function to store the address of the recipient and the amount of tokens to send:
if tx_balance > 0
then
(
match (Bytes.unpack bound_data: key_hash option) with
| None -> failwith "UNABLE_TO_UNPACK_RECIPIENT"
| Some (recipient_key_hash) ->
let recipient =
recipient_key_hash
|> Tezos.implicit_account
|> Tezos.address
in
let data = (recipient, abs(tx_balance)) in
(
data :: unshielding_reqs,
shielding_reqs,
{ storage with state = new_sapling_state }
)
)
Nothing new here, except for the type of the accumulator which you probably guessed from the type of loop_els
:
(address * nat) list * nat * storage
Once you get the address of the recipient and the transaction balance, you wrap them in a tuple that you store in the unshielding_reqs
list and you return the new Sapling state at the same time.
Shielding transactions
As expected, a strictly negative transaction balance is the hallmark of a shielding transaction.
Here is what to do when they happen:
else if tx_balance < 0
then
(
match (Bytes.unpack bound_data: key_hash option) with
| Some (_) -> failwith "UNEXPECTED_RECIPIENT"
| None ->
(
unshielding_reqs,
(shielding_amount + abs(tx_balance)),
{ storage with state = new_sapling_state }
)
)
The bound data should not contain a recipient address, so the contract fails if that’s the case.
Then, you update the shielding amount by adding the transaction balance, the amount to be transferred as a value. This amount will be used later to forge transfer transactions from the token contract to the Sapling contract.
Once done, you can return the updated map and the new Sapling state.
Sapling transfers
As usual, the simplest part of any Sapling contract 😅
else
(
unshielding_reqs,
shielding_amount,
{ storage with state = new_sapling_state }
)
Forging the operations
Before finishing the execution of the contract, you must forge the operations that will transfer tokens from the contract and to the contract.
To begin, you create the reference to the FA2 contract you target:
let op: operation =
match ((Tezos.get_entrypoint_opt "%transfer" s.fa2_contract): fa2_parameter contract option) with
| None -> failwith "%TRANFER_DOESNT_EXIST"
| Some contract ->
...
Next, you can handle the transfers of tokens to the Sapling contract:
let transfers_to: fa2_parameter =
[{
from_ = Tezos.get_sender () ;
txs = [{
to_ = Tezos.get_self_address () ;
token_id = s.token_id ;
amount = shielding_amount
}]
}]
in
You will use the shielding_amount
to create the parameters of the transfers:
from
: the address of the senderto
: the address of the Sapling contracttoken_id
: the id of the FA2 tokenamount
: the amount of tokens to be transferred
The newly created parameters will be stored in a value of type fa2_parameter
.
Now, you can handle the transfers of tokens from the Sapling contract:
let transfers_from: fa2_transaction list =
List.map
(
fun (req: (address * nat)) ->
{
to_ = req.0 ;
amount = req.1 ;
token_id = s.token_id ;
}
)
unshielding_reqs
in
The “unshielding requests” sit in a list of type (address * nat) list
, so you can easily map it to output a list of parameters of type fa2_transaction list
.
After creating the transfers_to
and transfers_from
lists, you can create the final parameter that will be sent to the transfer
entrypoint of the FA2 contract:
let transfers: fa2_parameter =
{ from_ = Tezos.get_self_address () ; txs = transfers_from } :: transfers_to
in
Tezos.transaction transfers 0tez contract
You create a first transaction for the tokens spent by the Sapling contract as a record before adding the record to the list of transactions for the tokens the Sapling contract will receive.
Once it’s done, you can forge the final operation that will be sent to the FA2 contract and voilà 🥳
Conclusion
In addition to being an amazing feature of the Tezos blockchain for anonymous transactions, Sapling is also a versatile one that allows not only the transfer of XTZ, but also the transfer of FA1.2 and FA2 tokens.
Because the balance of the Sapling pool is represented by an integer, it is possible to make it track different kinds of fungible tokens, or actually, any kind of asset that can be represented by a number.
The example contracts in this tutorial are pretty simple, but it is also possible to implement Sapling in more complex contracts, for instance, a contract can have multiple Sapling pools to transfer different tokens or a Sapling pool could be added to a decentralized exchange, the possibilities are endless!