Experimenting with Privacy using Zcash’s Sapling in Liquidity

Thomas
Dune Network
Published in
7 min readFeb 21, 2020

When Satoshi Nakamoto designed Bitcoin, he was mainly concerned with achieving consensus between peers so as to establish a credible digital currency. Anonymity — much like in the case of the Internet — was all the less a worry that Bitcoin addresses provide pseudonymity: until bitcoins are transferred to or from a cryptocurrency exchange, it is difficult to link them to a real-world identity. Yet, nowadays, it is extremely difficult to remain anonymous on public blockchains such as Bitcoin, which poses privacy concerns. Fortunately, a solution to this issue has come from the very science that made Bitcoin possible: cryptography. The Zcash cryptocurrency pioneered the use of zero-knowledge cryptography on the blockchain. In short, zero-knowledge cryptography allows one to prove they possess the solution to a specific problem of fixed size without revealing the solution. In the context of blockchains, this means the possibility to build transactions which can be verified correct without revealing its source, amount or destination to anyone but the parties involved.

Zero-knowledge cryptography on the blockchain has become a lot more practical thanks to amazing work by the ZCash team, called ‘Sapling’. We are excited to learn that the sapling framework is being ported to Tezos. A post by Nomadic recently introduced a shielded_tez contract in the Michelson language enabling private transactions. Concretely, you can create a "shielded" account with a special address (starting with "zet") to which you can transfer XTZ using a new sapling prefix in tezos-client. You may then transfer funds to other shielded addresses using zero-knowledge transactions which do not reveal the sender, amount and destination of the transfer. At any point in time, you may choose to unshield some of your shielded funds to any Tezos address. Provided this new address is not related to the one you used to shield funds, this arrangement creates full anonymity[1].

Elegantly, all this is achieved by introducing only two new types and one instruction to Michelson.

At Origin Labs, we were curious to see how easy it would be to:

  1. port sapling to the Liquidity language;
  2. push the technology further thanks to the expressiveness of Liquidity.

We achieved both and, in little time, we were able to build a mixer for ERC20-like tokens, which is to say the possibility to shield and unshield any token in a way similar to what shielded_tez does with XTZ. This speaks to the strength and expressiveness of our decompilation tool (Michelson to Liquidity) and the adaptiveness of Liquidity: it was straightforward to add the new types and instruction and to develop the mixer smart contract.

Below we go into more technical detail.

Adding Sapling to Liquidity

This part was quite straightforward. Recall that sapling introduces the two types (sapling_state and sapling_transaction), as well as one new instruction ( sapling_verify_update ). After decompiling the shielded_tez.tz contract from Michelson to Liquidity and cleaning it a little, we got the following smart contract:

type storage = sapling_state
let%entry main (tr, dest_opt) (storage : storage) =
(* verify the sapling transaction, and extract the balance and
final sapling state *)
let (balance,opt_st) =
sapling_verify_update tr storage "SaplingForTezosV1" in
let balance_tez =
0.000001dn *
(match%nat balance with (* get absolute value *)
| Plus exp9 -> exp9
| Minus exp9 -> exp9)
in
if balance > 0 then
(* case when the transfer unshields money (z_to_t) *)
let dest =
match dest_opt with
| None -> failwith ()
| Some dest -> dest
in
(* the contract creates a transfer operation of some of
its xtz to the transaction recipient *)
let transfer = Account.transfer ~dest:dest ~amount:balance_tez
in
let st = match opt_st with
| None -> failwith ()
| Some st -> st
in
(* everything is ok, return the transfer operation and
update the sapling state *)
([transfer], st)
else begin
(* case when the transfer either shields money (t_to_z)
or does a shielded transaction (z_to_z) *)
if 0dn <> Current.amount () - balance_tez then failwith ();
begin
match dest_opt with Some _ -> failwith () | _ -> ();
end;
let st = match opt_st with
| None -> failwith ()
| Some st -> st
in
(* everything is ok, just keep the xtz in the contract, and
update the sapling state *)
( ([] : operation list), st )

Although this contract could still be improved aesthetically, it is somewhat easier to follow than the Michelson version:

storage sapling_state;
parameter (pair sapling_transaction (option key_hash) );
code { UNPAIR ;
UNPAIR ;
DIP { SWAP ;
DIP {PUSH string "SaplingForTezosV1" ;} ;} ;
SAPLING_VERIFY_UPDATE ;
DUP ;
DIP{ ABS;
PUSH mutez 1;
MUL};
IFGT { DIP { SWAP;
ASSERT_SOME;
IMPLICIT_ACCOUNT};
UNIT;
TRANSFER_TOKENS;
NIL operation;
SWAP;
CONS;
DIP { ASSERT_SOME;}}
{
AMOUNT;
SUB;
PUSH mutez 0;
ASSERT_CMPEQ;
ASSERT_SOME;
DIP { ASSERT_NONE;};
NIL operation};
PAIR}

Building a Mixer for Generic Tokens

Building on this success, we decided to explore something which would have been quite painful to build using only Michelson: a mixer for ERC20-like tokens. Using such a contract, one would deposit their tokens, and unlinkably withdraw them to protect their anonymity. For this we needed some minor client-side tweaks of the sapling prototype of the Tezos code.

In the following, we suppose that a standard ERC20 contract has already been deployed. Our mixer will be used to shield the corresponding tokens.

Storage

Our mixer contract is rather simple (the full code is available at the end of the article). Its storage is:

type storage = {
sapling_state : sapling_state;
erc20addr : address;
}

The sapling state can be seen as the “masked database” containing the private transactions and balances: to the outside observer it is of no more use than a random sequence of bytes. The address of the token contract is hardcoded in the mixer.

Methods

The mixer contract has two entrypoints: receiveTokens and withdraw.

receiveTokens conforms with our specification for token contracts and receives tokens from the token contract, along with a sapling transaction which shields the corresponding quantity of tokens.

withdraw enables one to recover tokens previously shielded and send them to any account (preferably not the one with which they were shielded).

There is a method deposit which is not an entrypoint. It is triggered by a successful call to receiveTokens if the included sapling transaction is deemed correct.

A closer Technical View to our Mixer Contract

Below is the full code of our prototype mixer contract. First, note that this contract needs to implement the TokenReceiver interface:

contract type TokenReceiver = sig
type storage
val%entry receiveTokens : (address * nat * bytes option) -> _
(* more ... *)
end

Here is the actual contract:

[%%version 2.0]type storage = {
sapling_state : sapling_state;
erc20addr : address;
}
let%init storage = {
sapling_state = (0x : sapling_state);
erc20addr = KT1SAaFjYUD5KFYidYxPzpnf6HgFs4oAJuTz;
}
(* point of entry for shielding tokens *)
let%entry receiveTokens (
(_ : address), tokens, (bytes_opt : bytes option)) storage =
if Current.sender () <> storage.erc20addr then
failwith ("Wrong ERC contract sent the tokens",
Current.sender(),storage.erc20addr)
else
match bytes_opt with
| None -> failwith "No sapling transaction"
| Some tr ->
match (Bytes.unpack tr : sapling_transaction option) with
| None ->
failwith "Bad argument, should be sapling transaction."
| Some tr ->
deposit tr tokens storage
(* Note that this is not an entry point: it is called by
receiveTokens if the sapling transaction is valid. *)
let deposit (tr : sapling_transaction) (tokens:nat) storage =
let (balance,opt_st) = sapling_verify_update tr
storage.sapling_state "SaplingForTezosV1"
in
let balance =
match%nat balance with
| Plus balance ->
if balance = 0p then
balance
else
failwith (
"should not be unshielding in deposit",balance)
| Minus balance -> balance
in
if balance <> tokens then
failwith (
"Misleading amount in sapling transaction.",balance,tokens)
else
let st =
match opt_st with
| None -> failwith ("invalid shielded transaction")
| Some st -> st in
let storage = storage.sapling_state <- st in
[],storage
let%entry withdraw
((tr : sapling_transaction),(dest: bytes option)) storage =
let (balance,opt_st) = sapling_verify_update tr
storage.sapling_state "SaplingForTezosV1"
in
let balance =
match%nat balance with
| Minus _ ->
failwith (balance,"should not be shielding in withdraw")
| Plus balance -> balance
in
let st =
match opt_st with
| None -> failwith "invalid shielded transaction"
| Some st -> st
in
let storage = storage.sapling_state <- st in
[
storage.erc20addr.transfer
(Current.sender (),balance, (None : bytes option))
~amount:0dun
],storage

With this mixer contract, we are now able to transfer ERC20-tokens to a shielded account, transfer them between shielded accounts with full anonymity, and transfer back (unshield) the tokens to standard accounts.

Conclusion

We are excited to add to current contributions in the current push for anonymity in the blockchain community. The high-level expressiveness of Liquidity allowed us to quickly iterate over the existing prototype by Nomadic Labs. As proposals stabilize, we will start releasing stable code to the broader community. With its collect-call technology, Dune also provides an interesting solution to the current problem of fees for Sapling transactions in Tezos. Stay tuned for more!

  1. Of course, there need to be other users of the contract, and the amount being shielded and then unshielded must not be too specific. Moreover, since shielded transactions are submitted to the contract, the account submitting them needs to be unconnected to the shielding/unshielding address. Since fees in Dune would be paid by the contract itself, anyone could submit transactions for other users without having to pay these fees.

Contact us:

--

--