How to write Sapling contracts to transfer fungible tokens anonymously

Claude Barde
Coinmonks
8 min readApr 18, 2023

--

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 tokens
  • value : the transaction balance in nat

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 sender
  • to_ : the address of the contract
  • value : the transaction balance in nat

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_updatewill 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 sender
  • to : the address of the Sapling contract
  • token_id : the id of the FA2 token
  • amount : 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!

--

--

Claude Barde
Coinmonks

Self-taught developer interested in web3, smart contracts and functional programming