A deep-dive into DarkFi smart contracts
Table of Contents
Introduction
DarkFi is a new L1 protocol that enables the creation of private Dapps.
DarkFi is to Monero what Ethereum is to Bitcoin.
It features a fully programmable environment for smart contracts of all kinds. Privacy doesn’t extend only to the application layer but also to the Block Proposers. As the block proposers use the same privacy circuits that smart contracts leverage, privacy is extended to the consensus layer. That, coupled with built-in support for Tor1 and Nym2 by default, enables a genuinely sovereign and uncensorable network that is robust to shakedowns and antifragile.
The more state actors try to take it down, the more value it gains as the only genuinely sovereign solution.
In this blog post, we will talk about:
- Smart contracts in Defi and how they differ from the ones in Ethereum
- Anonymous Engineering in DarkFi
- A use case: The Money contract
A Sovereign Stack
DarkFi is not just an L1.
DarkFi is more than just an L1 protocol. It’s a comprehensive suite of libraries and SDKs designed to build tools for sovereign communities and individuals, providing a countermeasure to the ever-encroaching surveillance state. By leveraging the same foundational building blocks, the team has created a peer-to-peer (p2p) IRC-based alternative to Discord (called darkirc), avoiding reliance on centralized services. They’ve also developed a p2p issue tracker (named taud). The overarching goal is to make the project as antifragile as possible. This antifragility is also why DarkFi didn’t opt to become an L2 for Ethereum (or any other blockchain), as doing so would mean deriving its security, at least partially, from Ethereum. However, it would be straightforward for the network to transition to an L2 in the future, should the design goals evolve.
Upon examining the codebase, you’ll notice a minimalistic approach to dependencies, with a strong preference for the std
library. For instance, DarkFi doesn’t use tokio
for its async runtime, despite tokio
being a staple in the Rust ecosystem. Using dependencies other than the std
increases the attack surface for dependency poisoning. While some software projects might accept this risk, DarkFi does not. Also, the codebase re-implements functionality typically provided by external libraries. Lastly, the team chose to use Halo23 as their ZK backend due to its widespread use and the fact that it doesn’t require a trusted setup, which could theoretically be another attack vector.
A key design pattern in DarkFi is its modularity. Part of the consensus mechanism is implemented in smart contracts, making it easy to swap it out and replace it with a different algorithm if needed. The privacy-related functionality is made accessible to the user via circuits in a relatively simple language that currently compiles to Halo2, but could be compiled to any other ZK system if deemed more suitable.
Smart Contracts in DarkFi
Smart contracts are written in Rust, compiled to WASM, and executed by a WASM runtime. In that runtime, the developer can access the ZKVM, a small VM that runs zkas, a small zero-knowledge language that compiles to halo-2.
The developer writes a regular Rust program that performs the business logic. The Rust program is responsible for providing the public inputs to the zk circuit, which are used to validate the proof provided by the user alongside the calldata in the transaction. The user also uses the zk circuit to generate the proof by providing their private inputs. The proof is generated on the client side. Finally, the zk circuit is also designed by the developer and deployed alongside the smart contract, so the blockchain knows what zk circuit the smart contracts use..
Not everything needs to be constrained by the circuit except for the functionality that performs the privacy scheme.
You can read more about zkas in the DarkFi documentation4.
Account model
DarkFi is a UTXO-based blockchain. That means that in every transaction that transfers some coins, there needs to be an equal amount of inputs and outputs. When we transfer tokens from one account to another, we make two transfers:
- one to the address we want to transfer, let’s say X tokens
- one to a new address we own, transferring the rest of the tokens, let’s say Y-X
Alchemy5 has an excellent explainer about the differences between the UTXO and the Account model.
The UTXO models come contrast with other blockchains, like Ethereum, where there is an account model.
For example, let’s take a look at src/contract/money/src/client/transfer_v1.rs
6.
In the TransferCallBuilder
struct, we can find a field pub coins: Vec<OwnCoin>
. This field represents the unspent transaction outputs (UTXOs) the user owns and can spend. These coins are used in the build method to create inputs for the new transaction. In the account model, you would deduct the amount from the account balance rather than selecting specific UTXOs to spend.
pub fn build(&self) -> Result<TransferCallDebris> {
debug!("Building Money::TransferV1 contract call");
assert!(self.value != 0);
assert!(self.token_id.inner() != pallas::Base::zero());
if !self.clear_input {
assert!(!self.coins.is_empty());
}
// Ensure the coins given to us are all of the same token ID.
// The money contract base transfer doesn't allow conversions.
for coin in self.coins.iter() {
assert_eq!(self.token_id, coin.note.token_id);
}
...
Let’s see another example in the same source file:
for coin in self.coins.iter() {
if inputs_value >= self.value {
debug!("inputs_value >= value");
break
}
let leaf_position = coin.leaf_position;
let merkle_path = self.tree.witness(leaf_position, 0).unwrap();
inputs_value += coin.note.value;
let input = TransactionBuilderInputInfo {
leaf_position,
merkle_path,
secret: coin.secret,
note: coin.note.clone(),
};
inputs.push(input);
spent_coins.push(coin.clone());
}
In the code snippet above, each OwnCoin
in self.coins
is considered as an input for the transaction until the total value of the selected coins is greater than or equal to the value to be transferred.
The above example is, again, characteristic of the UTXO model, where transactions are created by specifying which UTXOs to spend.
Privacy
The privacy scheme is essential to writing smart contracts in DarkFi as they are not private “by default”. Instead, the user must use (or devise a new) privacy scheme that leverages the existence of the ZKVM and adds a privacy layer to the smart contract. The programmer must define the private inputs and the public inputs.
The DarkFi community calls this anonymous engineering, meaning the art of creating novel anonymous applications through the design of anonymous schemes (an example is the Sapling payment scheme we will discuss later in the article).
Let’s consider a simple DarkFi smart contract allowing users to transfer tokens. In this case:
- Private Inputs could include:
- The number of tokens to be transferred.
- The sender’s private key.
- The receiver’s address.
- Public Inputs could include:
- A commitment(e.g., a hash) to the number of tokens to be transferred.
- A signature derived from the sender’s private key.
- The receiver’s address.
Here’s how it works: The user generates a zero-knowledge proof using the private inputs and the zk circuits of the smart contracts they want to interact with. This proof, along with the public inputs, is sent to the network. The nodes on the network can then verify the proof using the public inputs without learning any information about the private inputs.
Abstractions
An interesting differentiation with more smart contract languages is that the smart contract is written in pure Rust, not some DSL. That means that the developer is exposed to the full API of the runtime and must code all functionality that is usually auto-generated by the DSL compiler, such as, for example, decoding a contract call to the correct function selector to invoke the proper function. As DarkFi is still in development, the developers decided not to design abstractions but revisit them later as developers use the platform.
Lifecycle of a transaction
Let’s see the fundamental stages of a transaction in DarkFi:
- Execute contract calls
- Verify transactions
- Verify ZK proofs
These stages happen serially, and if an error arises at any stage (e.g, a signature can’t be verified), the whole transaction will revert. The transaction is added to the blockchain at the end, and the state affected by the contract calls is committed to the database.
Figure 1: The lifecycle of a transaction
This process happens in verify_transaction()
, which can be found in src/validator/verification.rs
7.
Lifecycle of a contract call
A smart contract call goes through three distinct states. For each state, the runtime will invoke a specific function from the smart contract, which we define ourselves. Think of them like hooks that the runtime expects will perform all required activities and advance the smart contract to the next stage.
The VM goes through these states for every contract call in order. If any call fails, the whole transaction reverts.
The states are roughly the following:
- Get Metadata: Decode contract call raw data and get public inputs for proof verification
- Contract call state transition: Perform business logic and read from the database
- Commit state changes: Commit any changes to the database
Database
DarkFi uses a simple key-value store interface for storage. The developer defines keys or “buckets” in the database and then stores arbitrary values (or serialized data) into these buckets.
A smart contract has read access to all contracts in the state transition function but has write access to its database only during the commit state function.
DarkFi uses Sled8 as the embedded database backend, known for its simplicity and performance.
Contract Call - composition
Currently, contracts can’t call other contract functions but can read from their database. In Ethereum terms, a contract has access to the storage of another contract, so if Contract A
has a uint256
storage variable b
, Contract B
can do uint256 variable = ContractA(addressA).b()
. But, it can’t do uint256 variable = ContractA(addressA).foo()
.
Moreover, when we make multiple contract calls in a single transaction, every contract call is done atomically, meaning that the VM will go through all three states (metadata, transition, update) of the call before advancing to the next. Thus, composition currently is achieved by chaining multiple contract calls together and making the contracts read one another’s storage.
In the image below, we can see the difference in composition between Ethereum and DarkFi. In Ethereum, it’s easier, as the developer can make internal calls from one contract to another. In contrast, in DarkFi, the developer needs to account for every subsequent call being an atomic transaction, and the contracts need to know beforehand from where they can fetch the required state.
Figure 2: Contract composition in DarkFi and in Ethereum
Smart contract code organisation
Finally, let’s talk about the smart contract code organisation. It’s more complex than your typical smart contract in Ethereum, so take note:
|entrypoint/
├─ mint.rs
├─ mod.rs
proof/
├─ mint.zk
client/
├─ do_something.rs
├─ mod.rs
models.rs
lib.rs
error.rs
entrypoint.rs
cargo.toml
entrypoint/
: we add all the core functionality of the smart contract, creating a source file per function call. That could seem excessive, but remember that every function call requires three different functions invoked at the various states of the smart contract.proof/
: we store all the circuits we use in the smart contract. Ideally, a source file per function call is a good balance.client/
(optional): we add a sort of Rust SDK for our smart contract. Helper functions so that users can easily create the appropriate transactions to be submitted to a DarkFi Node.lib.rs
: we define the crate modules, the function selector, and the constants.entrypoint.rs
: the entry point for the runtime (more on that later)error.rs
: we define the errors of the smart contractmodel.rs
: we define the structs of the smart contract, such as the inputs of the functions
Finally, we need to talk about one last thing before we are ready to read a smart contract in DarkFi.
Anonymous Engineering - Sapling Scheme
Anonymous Engineering is a fascinating new land of opportunity, and we can’t possibly cover everything in a single blog post. For the requirements of our narrow analysis, we will talk about the following constructs:
- Pedersen Commitments
- Poseidon Hashes
- The Sapling Scheme (A privacy scheme for token transfers)
Pedersen Commitments
Pedersen’s commitment, named after its creator, Torben Pedersen, is a cryptographic primitive used to commit to a value while keeping it hidden from others. It provides a way to commit to a value without revealing any information about the value itself.
Pedersen’s commitment scheme is based on the computational hardness of the discrete logarithm problem in a group. Let’s see a quick overview:
- Setup: A commitment key pair is generated. It involves selecting a large prime number, a generator of a cyclic group modulo the prime, and calculating the public commitment key.
- Commitment: To commit to a value, the sender selects a random “blinding factor” and multiplies it with the generator to the power of the value, along with another generator raised to the power of the blinding factor. The resulting commitment is the combination of these two values.
- Opening: When the sender wants to reveal the committed value, they provide both the committed value and the blinding factor.
Thus, Pedersen’s commitment scheme provides the following properties:
- Binding: It is computationally infeasible to find two different values that result in the same commitment.
- Hiding: Without knowledge of the blinding factor, it is computationally infeasible to determine the committed value.
You can read more about the scheme in this Stack Exchange9 answer.
Poseidon Hashes
Posdeion Hash10 is a cryptographic hash function designed for efficient and secure data hashing. It is a member of the family of hash functions based on the sponge construction, similar to Keccak (which includes the Keccak-256 variant, often denoted as Keccak256). While Poseidon hash and Keccak256 are hash functions, they have notable differences.
It’s designed and optimized for zero-knowledge (zk) protocol applications, making it a preferred choice over Keccak-256 or other hash functions.
Thus, whenever you read “Poseidon hash”, think of regular hashing but with a more zk-friendly algorithm than the one used in blockchains like Ethereum.
Sapling Scheme
It’s like sending a letter in an envelope. Only the person who opens the envelope knows who the sender is, who the receiver is, and what the message is. Everyone else can only see that a letter was sent.
At its core, it consists of two ZK proofs. One to spend a previously minted coin (burn proof) and one to mint the new coins (mint proof). With every transaction, we destroy the “sent” tokens and create new tokens for the receiver.
Figure 3: We burn 3 Inputs(Coins) \(v_1, v_2, v_3\) and we mint two Output(Coins) \(v_4, v_5\)
Essentially, we send (mint) some of the tokens to the receiver (thus one output), and the rest of the tokens are sent (minted) to an address that we control (the second output).
The scheme works as follows:
- Initially, we create a hash of the attributes of the input (input value, token id, input serial number). The serial number is a unique number for this input, chosen randomly.
- Then, we commit (hash) of the value
- Then, we commit (hash) of the token id
- Then, we generate
blinds
, random values that are used by the zk circuit to hide the values (token ID, token value) when checking the public input - Finally, we generate a public key from the private key that is used to create the input
The serial number of a coin is a unique identifier for each minted coin. It is used to prevent double-spending of the same coin. When a coin is spent, the serial number is revealed to the network, and any subsequent attempts to spend a coin with the same serial number will be rejected. The serial number is known only to the coin’s owner until it is spent.
On the other hand, the token id is a unique identifier for a type of token or coin. It is derived from the public key of the mint authority and the derivation path (as seen in src/contract/money/proof/token_mint_v1.zk
11). All coins of the same type (e.g., all $DRK tokens) will have the same token ID. The token ID is public information used to distinguish between different kinds of coins in the network.
Thus, we have the following private input for the zk circuit:
// Witness values
let value = 42;
let token_id = pallas::Base::random(&mut OsRng);
let value_blind = pallas::Scalar::random(&mut OsRng);
let token_blind = pallas::Scalar::random(&mut OsRng);
let serial = pallas::Base::random(&mut OsRng);
let public_key = PublicKey::from_secret(SecretKey::random(&mut OsRng));
let (pub_x, pub_y) = public_key.xy();
let private_input = vec![
Witness::Base(Value::known(pub_x)),
Witness::Base(Value::known(pub_y)),
Witness::Base(Value::known(pallas::Base::from(value))),
Witness::Base(Value::known(token_id)),
Witness::Base(Value::known(serial)),
Witness::Scalar(Value::known(value_blind)),
Witness::Scalar(Value::known(token_blind)),
];
The above private input constrains the public inputs below. That means that a node in the network can verify that the public input was generated from a valid private input (witness) that was passed to the zk circuit.
// Create the public inputs
let msgs = [pub_x, pub_y, pallas::Base::from(value), token_id, serial];
let input = poseidon_hash(msgs);
let value_commit = pedersen_commitment_u64(value, value_blind);
let value_coords = value_commit.to_affine().coordinates().unwrap();
let token_commit = pedersen_commitment_base(token_id, token_blind);
let token_coords = token_commit.to_affine().coordinates().unwrap();
let public_inputs = vec![
input,
*value_coords.x(),
*value_coords.y(),
*token_coords.x(),
*token_coords.y(),
];
So, when a user wants to transfer tokens using the smart contract:
- It reads the smart contract’s zkas binary and the private input to generate a circuit for the user
- The user creates a proof using the private input, the circuit, and their private key
- The user broadcasts to the network the public input alongside the proof. The network verifies that the proof is valid for that public input.
- Thus, the “Input” is marked as valid by the network and added to the Merklee tree of valid Inputs (Coins)
For the Input to be used by the other user who wants to spend it, they will use the published public input when they perform the burn phase of the protocol, which spends the Input.
Money
Figure 4: Mr.Crabs looking for money, money, money, money
Money is one of the “native” smart contracts shipped by the core DarkFi team and performs some specific functionality in the core protocol (e.g., payments).
It’s a smart contract roughly analogous to an ERC20 token factory. Anyone can use Money to mint new tokens and perform every activity expected from a token standard, like transferring, minting, and burning tokens. The native token of the blockchain itself, $DRK, is a token that lives in this smart contract.
We will go through the transfer
functionality of the Money contract.
It’s a useful example to study as it contains several of the things we have discussed about:
- it demonstrates the smart contract call composition and structure
- it implements an anonymous scheme which is implemented in zkas
- It demonstrates the interaction of the Rust smart contract with the zkas layer
You can either clone the DarkFi repository locally or visit it on GitHub12 to follow along. Take care with the installation instructions, as it requires +nightly+
for your Rust toolchain and the WASM32
target.
lib.rs
It is the main source file for the Rust project (smart contracts are written in Rust, after all).
The first thing we observe is the function selector enum:
/// Functions available in the contract
#[repr(u8)]
pub enum MoneyFunction {
//Fee = 0x00,
GenesisMintV1 = 0x01,
TransferV1 = 0x02,
OtcSwapV1 = 0x03,
TokenMintV1 = 0x04,
TokenFreezeV1 = 0x05,
StakeV1 = 0x06,
UnstakeV1 = 0x07,
}
impl TryFrom<u8> for MoneyFunction {
type Error = ContractError;
fn try_from(b: u8) -> core::result::Result<Self, Self::Error> {
match b {
//0x00 => Ok(Self::Fee),
0x01 => Ok(Self::GenesisMintV1),
0x02 => Ok(Self::TransferV1),
0x03 => Ok(Self::OtcSwapV1),
0x04 => Ok(Self::TokenMintV1),
0x05 => Ok(Self::TokenFreezeV1),
0x06 => Ok(Self::StakeV1),
0x07 => Ok(Self::UnstakeV1),
_ => Err(ContractError::InvalidFunction),
}
}
}
We will use this enum
and the byte conversion to differentiate between contract calls. Every contract needs an enum and a TryFrom<u8>
implementation. You will observe that usually, this is handled automatically by the smart contract languages, but in DarkFi, there is no DSL or intermediary layer. The code we write is exactly the code the runtime will execute.
Thus, it’s required that we implement the function selector functionality ourselves, which means that we need to assign a function selector to every function of our smart contract.
In DarkFi, the function selector is a single byte, the first byte of the contract call. That means that a contract can have up to 255 functions.
Next, we define the keys of the various databases (buckets) that the smart contract uses.
// These are the different sled trees that will be created
pub const MONEY_CONTRACT_INFO_TREE: &str = "info";
pub const MONEY_CONTRACT_COINS_TREE: &str = "coins";
pub const MONEY_CONTRACT_COIN_ROOTS_TREE: &str = "coin_roots";
pub const MONEY_CONTRACT_NULLIFIERS_TREE: &str = "nullifiers";
pub const MONEY_CONTRACT_TOKEN_FREEZE_TREE: &str = "token_freezes";
// These are keys inside the info tree
pub const MONEY_CONTRACT_DB_VERSION: &str = "db_version";
pub const MONEY_CONTRACT_COIN_MERKLE_TREE: &str = "coin_tree";
pub const MONEY_CONTRACT_LATEST_COIN_ROOT: &str = "last_root";
pub const MONEY_CONTRACT_FAUCET_PUBKEYS: &str = "faucet_pubkeys";
Now that we have defined the smart contract’s basics, let’s discuss Money’s data structures.
model.rs
The first struct
that we see is the Coin
. It’s a wrapper struct around a hash
of various data about coin output.
pub struct Coin(pallas::Base);
pallas::Base
is a type that defines a Base field element on a Pallas curve. Pallas is a curve like secp256k1
used in Bitcoin and Ethereum to create key pairs. Electric Coin Co, the creator of Zcash, has released a nice blog post13 about them.
(We skip ClearInput
because it’s an implementation detail related to the faucet – nothing too interesting)
Then, we move to the private inputs.
As we have already mentioned, the private inputs are generated by the client wallet to generate the proof, and they are never published to the nodes. The nodes can verify the proofs in the smart contracts using the circuits we will define in zkas.
In the Input
struct, we define the following:
- A Pedersen commitment to the value (the number) of the token we transfer
- A Pedersen commitment to the id of the token
- A nullifier: a Field Base element. The nullifier will be used to invalidate the input when it’s used
- The Merkle root of the tree of all the tokens (at the time of input generation)
- A spend hook: essentially a way to call a function right after a token transfer
- A Pedersen commitment to encrypted user data. The commitment is useful in case the hook is used and the user wants to pass encrypted data to the subsequent contract call
- The **public key is derived from the private key that was used to sign the contract call that uses this Input
Thus, even if the Input is public, the data included in the Input does not disclose any information for the transfer. Neither the token id nor the amount of tokens is disclosed. The only information disclosed is the address that performed the creation of this input.
/// A contract call's anonymous input
#[derive(Clone, Debug, PartialEq, SerialEncodable, SerialDecodable)]
pub struct Input {
/// Pedersen commitment for the input's value
pub value_commit: pallas::Point,
/// Pedersen commitment for the input's token ID
pub token_commit: pallas::Point,
/// Revealed nullifier
pub nullifier: Nullifier,
/// Revealed Merkle root
pub merkle_root: MerkleNode,
/// Spend hook used to invoke other contracts.
/// If this value is nonzero then the subsequent contract call in the tx
/// must have this value as its ID.
pub spend_hook: pallas::Base,
/// Encrypted user data field. An encrypted commitment to arbitrary data.
/// When spend hook is set (it is nonzero), then this field may be user
/// to pass data to the invoked contract.
pub user_data_enc: pallas::Base,
/// Public key for the signature
pub signature_public: PublicKey,
}
This Input is used throughout the smart contract to perform various actions, such as the transfer of tokens. At this point, you may wonder how we came up with the particular input, and to answer that, we need to pause a little and talk about Privacy Schemes and, more specifically, the Sapling Payment Scheme.
Considering the Sapling Scheme we mentioned above, the Output struct should be all but obvious. It includes a commitment to the value, a commitment to the token ID, and, of course, the hash of the Input (or coin) used.
/// A `Coin` represented in the Money state
#[derive(Debug, Clone, Copy, Eq, PartialEq, SerialEncodable, SerialDecodable)]
pub struct Coin(pallas::Base);
/// A contract call's anonymous output
#[derive(Clone, Debug, PartialEq, SerialEncodable, SerialDecodable)]
pub struct Output {
/// Pedersen commitment for the output's value
pub value_commit: pallas::Point,
/// Pedersen commitment for the output's token ID
pub token_commit: pallas::Point,
/// Minted coin
pub coin: Coin,
/// AEAD encrypted note
pub note: AeadEncryptedNote,
}
entrypoint.rs
The entrypoint.rs
file is the main entry point for the Darkfi contract. It contains the core functions used to interact with the contract, including initializing it, processing instructions, and updating its state.
It matches the function selector part of the transaction calldata and invokes the appropriate code path. Although nothing stops us from implementing the logging in that source file, it’s better to place all the logic of a particular function in its source file. The current best practice is identifying the code in entrypoint/<function_name>
.
Every entrypoint/<function_name>
implements all three functions that are called to advance a transaction to the respective state (metadata, state_transition, update).
-
init_contract
: This function is called when the contract is deployed and initialized. It sets up the necessary databases and prepares them with initial data if necessary. It also bundles the zkas circuits to be used with functions provided by the contract. -
get_metadata
: This function is used by the wasm VM’s host to fetch the necessary metadata for verifying signatures and zk proofs. The payload given here is all the contract calls in the transaction. -
process_instruction
: This function verifies a state transition and produces a state update if everything is successful. This step should happen after the host successfully verifies the metadata from g’et_metadata ()`. -
process_update
: This function attempts to write a given state update provided the previous steps of the contract call execution were successful. It’s the last in line and assumes the transaction/call was successful. The payload given to the function is the updated data retrieved fromprocess_instruction()
.
entrypoint/transfer_v1.rs
So, let’s take a closer look into the transfer
function of the Money
contract. It has analogous functionality to ERC20::transferFrom
in Ethereum.
Again, the function is implemented in three different state functions:
money_transfer_get_metadata_v1
that builds the circuit’s public and private inputsmoney_transfer_process_instruction_v1
that performs the state transition (i.e., transfer of funds)money_transfer_process_update_v1
that performs the state update
Let’s see them in detail.
get_metadata
The call_idx
represents the index of the current contract call in the list of all contract calls within a transaction. So we first take the transaction calldata (from all calls submitted in this transaction) concerning this particular contract. The call_idx
is provided by the VM.
Next, we deserialize the data from that particular ContractCall,
starting after the first byte (since the first byte is the function selector we have used already). We deserialize it into the expected struct, MoneyTransferParamsV1
.
In essence, we manually do what is automatically handled in Ethereum. When writing solidity, we define our function as foo(uint a, string memory b)
, which implies that we expect a particular calldata to be provided when making this function call. Specifically, we hope the calldata to abi-encode
a number and a string with an arbitrary length in this particular order.
In DarkFi, the developer expresses the “function arguments” by defining the deserialization of the calldata into a specific struct. Anything else, and this function will fail.
Then, it simply loops through the inputs and outputs of the calldata and serializes them into the public input that the circuit expects. What circuit? The ZK circuit, of course, that we have defined in zkas
is used to generate the proofs (using private data) and validate them (using public data).
It’s interesting to note that the transfer function uses two circuits, one to burn the inputs (send the tokens) and one to mint the outputs (receive the tokens). Of course, this is specific to the use case, and the smart contract could use any number of circuits, for which the smart contract would need to serialize the public inputs similarly.
/// `get_metadata` function for `Money::TransferV1`
pub(crate) fn money_transfer_get_metadata_v1(
_cid: ContractId,
call_idx: u32,
calls: Vec<ContractCall>,
) -> Result<Vec<u8>, ContractError> {
let self_ = &calls[call_idx as usize];
let params: MoneyTransferParamsV1 = deserialize(&self_.data[1..])?;
// Public inputs for the ZK proofs we have to verify
let mut zk_public_inputs: Vec<(String, Vec<pallas::Base>)> = vec![];
// Public keys for the transaction signatures we have to verify
let mut signature_pubkeys: Vec<PublicKey> = vec![];
// Take all the pubkeys from any clear inputs
for input in ¶ms.clear_inputs {
signature_pubkeys.push(input.signature_public);
}
// Grab the pedersen commitments and signature pubkeys from the
// anonymous inputs
for input in ¶ms.inputs {
let value_coords = input.value_commit.to_affine().coordinates().unwrap();
let (sig_x, sig_y) = input.signature_public.xy();
// It is very important that these are in the same order as the
// `constrain_instance` calls in the zkas code.
// Otherwise verification will fail.
zk_public_inputs.push((
MONEY_CONTRACT_ZKAS_BURN_NS_V1.to_string(),
vec![
input.nullifier.inner(),
*value_coords.x(),
*value_coords.y(),
input.token_commit,
input.merkle_root.inner(),
input.user_data_enc,
input.spend_hook,
sig_x,
sig_y,
],
));
signature_pubkeys.push(input.signature_public);
}
// Grab the pedersen commitments from the anonymous outputs
for output in ¶ms.outputs {
let value_coords = output.value_commit.to_affine().coordinates().unwrap();
zk_public_inputs.push((
MONEY_CONTRACT_ZKAS_MINT_NS_V1.to_string(),
vec![output.coin.inner(), *value_coords.x(), *value_coords.y(), output.token_commit],
));
}
// Serialize everything gathered and return it
let mut metadata = vec![];
zk_public_inputs.encode(&mut metadata)?;
signature_pubkeys.encode(&mut metadata)?;
Ok(metadata)
}
Since we are writing the smart contract, we care about validation, and by that, we will be looking here into how the nodes validate a transaction with a proof and a set of public inputs. So, this function serializes the public inputs
fed into the circuit to validate the proof the user has provided as part of the transaction. It’s similar to calling ecrecover14 in the EVM to verify that a particular address created a specific signature for a message. The public input is a vector of tuples (String, Vec<pallas::Base>),
with the String
serving as the key to fetch the correct binary code of the circuit. The constant we use here is defined in the lib.rs
source file and is simply the circuit’s name: BURN_V1
.
Do I always need to use a circuit
No
If a smart contract doesn’t need any privacy-related functionality, then it’s unnecessary to leverage the zk vm; thus, no circuit is required.
In that case, the metadata function should return an empty vector.
# The definition of our circuit
circuit "Burn_V1" {
# Poseidon hash of the nullifier
nullifier = poseidon_hash(secret, serial);
constrain_instance(nullifier);
# Pedersen commitment for coin's value
vcv = ec_mul_short(value, VALUE_COMMIT_VALUE);
vcr = ec_mul(value_blind, VALUE_COMMIT_RANDOM);
value_commit = ec_add(vcv, vcr);
# Since value_commit is a curve point, we fetch its coordinates
# and constrain them:
constrain_instance(ec_get_x(value_commit));
constrain_instance(ec_get_y(value_commit));
[...]
}
Now that we have implemented the metadata function, which is the serialization of the public inputs used by the smart contract to verify the user-supplied proofs, we advance to implement the business logic of the smart contract.
process_instruction
To get going, open the source file at src/contract/money/entrypoint/transfer_v1.rs
15 and have it open while I comment on it.
At first glance, the business logic implementation seems unwieldy for a simple token transfer. Still, we must remember that DarkFi still needs to implement abstractions. Hence, the developer needs to do many things “magically” by the compiler and the EVM (in Ethereum, for example).
We start by verifying that there are inputs and outputs. Then, we advance to access the storage slots of the necessary variables in the computations. We perform a db_lookup
for the particular cid
(contract id). The constant variables (e.g. MONEY_CONTRACT_INFO_TREE
) are nothing else than the simple String
that we have defined in lib.rs
for simplicity. In these lookups, we get the handles for the “sub-databases” used by this particular contract. Like namespacing a vast key-value
store into distinct buckets of data, which we can store key-value pairs. This has been set up during the init_contract
function defined in entrypoint.rs
.
The rest of the function is specific to the token transfer and the sapling scheme. Since it’s very well commented, I will only go into some detail.
process_update
Finally, we commit the state update to the database. We get the database handles once more and then use the function db_set
to add the data to the contract’s storage.
The update that we are adding to the state has the following structure:
pub struct Nullifier(pallas::Base);
pub struct Coin(pallas::Base);
pub struct MoneyTransferUpdateV1 {
/// Revealed nullifiers
pub nullifiers: Vec<Nullifier>,
/// Minted coins
pub coins: Vec<Coin>,
}
A couple of interesting points:
db_set
can only be called in this function. It will error if called in any of the previously mentioned functions and it’s signature is:pub fn db_set(db_handle: DbHandle, key: &[u8], value: &[u8]) -> GenericResult<()>
.- The data we store are
Coin
andNullifier,
wrapper types around apallas::Base,
a number. We are storing numbers. - You will observe that we are using the value of the variables (e.g., the nullifier) as the key for the database, adding an empty slice as its value (it’s a key-value store). We do this because we only care to add the numbers into a set and easily verify if they are new or if we have added them. We don’t want to store any information about them, except that they have existed at some point. Thus, in the database that we store are (key-value) combinations where the keys are numbers, and the values are empty slices (if you are a Rust aficionado, this is essentially a HashSet).
/// `process_update` function for `Money::TransferV1`
pub(crate) fn money_transfer_process_update_v1(
cid: ContractId,
update: MoneyTransferUpdateV1,
) -> ContractResult {
// Grab all necessary db handles for where we want to write
let info_db = db_lookup(cid, MONEY_CONTRACT_INFO_TREE)?;
let coins_db = db_lookup(cid, MONEY_CONTRACT_COINS_TREE)?;
let nullifiers_db = db_lookup(cid, MONEY_CONTRACT_NULLIFIERS_TREE)?;
let coin_roots_db = db_lookup(cid, MONEY_CONTRACT_COIN_ROOTS_TREE)?;
msg!("[TransferV1] Adding new nullifiers to the set");
for nullifier in update.nullifiers {
db_set(nullifiers_db, &serialize(&nullifier), &[])?;
}
msg!("[TransferV1] Adding new coins to the set");
for coin in &update.coins {
db_set(coins_db, &serialize(coin), &[])?;
}
msg!("[TransferV1] Adding new coins to the Merkle tree");
let coins: Vec<_> = update.coins.iter().map(|x| MerkleNode::from(x.inner())).collect();
merkle_add(
info_db,
coin_roots_db,
&serialize(&MONEY_CONTRACT_LATEST_COIN_ROOT),
&serialize(&MONEY_CONTRACT_COIN_MERKLE_TREE),
&coins,
)?;
Ok(())
}
And this concludes the validation part of the business logic, which is that of the smart contract.
Now, we move to the ZK circuit that generates and validates the proofs.
ZK circuit
As we have discussed above, the transfer uses two distinct circuits:
- one to verify that the outputs are correctly burned
- one to verify that the inputs are correctly minted
For this blog post, we will take a look at the src/contract/money/proof/burn_v1.zk
16 circuit.
The circuit implements the sapling privacy scheme (explained above). The scheme informs the circuit about what needs to be constrained and how. The circuit would be different when implementing a another use case, such as a DEX with privacy features.
BURN_V1
Let’s go through the code:
-
k = 13; field = “pallas”;: These lines define parameters for the circuit. k is the number of rows in the circuit, and field is the name of the finite field used for computations. These will be provided by the DarkFi team in the documentation and later will be abstracted away by the toolchain.
# The k parameter defining the number of rows used in our circuit (2^k) k = 13; field = "pallas";
- The constant “Burn_V1” block defines constants used in the circuit. The constants used in the circuit, such as
VALUE_COMMIT_VALUE
,VALUE_COMMIT_RANDOM
, andNULLIFIER_K
, are curve points used in elliptic curve operations. The protocol provides them and can be used by the developer in the circuit. You can find them in src/zk/vm.rs.VALUE_COMMIT_VALUE
: This is a point on the elliptic curve that is used as a generator for the Pedersen commitment scheme. It’s used to multiply with the value that you want to commit to. The typeEcFixedPointShort
indicates that this point is represented in a compressed form to save space.VALUE_COMMIT_RANDOM
: This is another point on the elliptic curve that is used as a generator for the Pedersen commitment scheme. It’s used to multiply with the blinding factor in the commitment. The typeEcFixedPoint
indicates that this point is represented in an uncompressed form.NULLIFIER_K
: This is a base point on the elliptic curve used to generate nullifiers and public keys. Nullifiers prevent double-spending, and public keys are used in the signature scheme. The typeEcFixedPointBase
indicates that this point is a base point of the elliptic curve.
# The constants we define for our circuit constant "Burn_V1" { EcFixedPointShort VALUE_COMMIT_VALUE, EcFixedPoint VALUE_COMMIT_RANDOM, EcFixedPointBase NULLIFIER_K, }
-
The witness “Burn_V1” block defines the witness values, which are the private inputs to the circuit. These include the value of the coin being burned, the token id, various blinding factors, and other values. This is specific to the privacy scheme we will use in the smart contract design. In our case, it’s defined in the Sapling Scheme.
# The witness values we define for our circuit witness "Burn_V1" { # The value of this coin Base value, # The token ID Base token, # Random blinding factor for value commitment Scalar value_blind, # Random blinding factor for the token ID Base token_blind, # Unique serial number corresponding to this coin Base serial, # Allows composing this ZK proof to invoke other contracts Base spend_hook, # Data passed from this coin to the invoked contract Base user_data, # Blinding factor for the encrypted user_data Base user_data_blind, # Secret key used to derive nullifier and coin's public key Base secret, # Leaf position of the coin in the Merkle tree of coins Uint32 leaf_pos, # Merkle path to the coin MerklePath path, # Secret key used to derive public key for the tx signature Base signature_secret, }
- The circuit “Burn_V1” block defines the actual circuit. It includes various computations and constraints that must be satisfied for the proof to be valid. For example, it computes a nullifier (a value derived from the secret and the serial number), commitments for the coin’s value and token ID, a public key derived from the secret, and a Merkle root of the coin’s inclusion in a Merkle tree.
# The definition of our circuit
circuit "Burn_V1" {
# Poseidon hash of the nullifier
nullifier = poseidon_hash(secret, serial);
constrain_instance(nullifier);
# Pedersen commitment for coin's value
vcv = ec_mul_short(value, VALUE_COMMIT_VALUE);
vcr = ec_mul(value_blind, VALUE_COMMIT_RANDOM);
value_commit = ec_add(vcv, vcr);
# Since value_commit is a curve point, we fetch its coordinates
# and constrain them:
constrain_instance(ec_get_x(value_commit));
constrain_instance(ec_get_y(value_commit));
# Commitment for coin's token ID. We do a poseidon hash since it's
# cheaper than EC operations and doesn't need the homomorphic prop.
token_commit = poseidon_hash(token, token_blind);
constrain_instance(token_commit);
# Derive the public key used in the coin from its secret counterpart
pub = ec_mul_base(secret, NULLIFIER_K);
# Coin hash
C = poseidon_hash(
ec_get_x(pub),
ec_get_y(pub),
value,
token,
serial,
spend_hook,
user_data,
);
# With this, we can actually produce a fake coin of value 0
# above and use it as a dummy input. The inclusion merkle tree
# has a 0x00 leaf at position 0, so zero_cond will output value
# iff value is 0 - which is equivalent to 0x00 so that's the
# trick we use to make the inclusion proof.
coin_incl = zero_cond(value, C);
# Merkle root
root = merkle_root(leaf_pos, path, coin_incl);
constrain_instance(root);
# Export user_data
user_data_enc = poseidon_hash(user_data, user_data_blind);
constrain_instance(user_data_enc);
# Reveal spend_hook
constrain_instance(spend_hook);
# Finally, we derive a public key for the signature and
# constrain its coordinates:
signature_public = ec_mul_base(signature_secret, NULLIFIER_K);
constrain_instance(ec_get_x(signature_public));
constrain_instance(ec_get_y(signature_public));
# At this point we've enforced all of our public inputs.
}
Let’s look at the circuit in detail:
-
vcv = ec_mul_short(value, VALUE_COMMIT_VALUE);
- This line multiplies the coin’s value by a constantVALUE_COMMIT_VALUE
. This constant is part of the Pedersen commitment scheme and is a point on the elliptic curve. -
vcr = ec_mul(value_blind, VALUE_COMMIT_RANDOM);
- This line multiplies a random blinding factor value_blind by another constantVALUE_COMMIT_RANDOM
. The blinding factor ensures that the commitment doesn’t reveal any information about the value. The constantVALUE_COMMIT_RANDOM
is another point on the elliptic curve. -
value_commit = ec_add(vcv, vcr);
- This line adds the two results together to form the final Pedersen commitment. The addition operation here is not a regular addition but an elliptic curve addition.
Using different constants to multiply the value and the blinding factor ensures that even if someone knows the value and the commitment, they can’t determine the blinding factor. This is due to the Discrete Logarithm Problem (DLP), which is computationally hard to solve. Adding the two results forms the final commitment, which hides the actual value and the blinding factor.
Conclusion
Developing smart contracts for DarkFi will be challenging, but not because of the actual DevEx.
Sure, it’s more complex than developing a smart contract in Solidity, as the developer has to implement lots of boilerplate code, but this is to be expected for a nascent layer one blockchain.
The actual challenge will be when designing the smart contracts and the protocol in general, as the developer will be required to implement privacy schemes if they want to have privacy features. That requires a different mindset that divides the protocol between the private and the public inputs and thinking through the security considerations for them.
Not constraining the protocol or not designing the inputs appropriately could lead to security flaws that attackers could leverage.
Next Steps
The best way forward for new developers is to analyze the existing smart contracts to understand the application’s design scope and then develop the privacy scheme to support their dapp. Once you nail down the privacy scheme you want to implement, the rest of the smart contract should be much easier.
Moreover:
- Visit the tests17 for the Money contract and see how one is expected to use it in practice. You can run said tests with the Makefile18
- Join the DarkFi irc19, which is a custom-built, p2p IRC protocol built on the p2p library of DarkFi. The community holds dev calls every Monday 16:00 CET20. You can either install it manually or use Darkfi-Starter21, a project I built and the easiest way to setup IRC
- Set up a devnet22 and perform some actions using the provided scripts and wallet. See the source code to understand what these scripts do and, ideally, alter them a bit to see what will happen
Let there be Dark!
References
-
https://darkrenaissance.github.io/darkfi/zkas/index.html>Â ↩
-
https://github.com/darkrenaissance/darkfi/blob/master/src/contract/money/src/client/transfer_v1.rs ↩
-
https://github.com/darkrenaissance/darkfi/blob/01a1ade3b651f2a37211815e71d0f78513a56fa6/src/validator/verification.rs#L287Â ↩
-
https://crypto.stackexchange.com/questions/64437/what-is-a-pedersen-commitment ↩
-
https://github.com/darkrenaissance/darkfi/blob/master/src/contract/money/proof/token_mint_v1.zk ↩
-
https://electriccoin.co/blog/the-pasta-curves-for-halo-2-and-beyond/Â ↩
-
https://github.com/darkrenaissance/darkfi/blob/master/src/contract/money/src/entrypoint/transfer_v1.rs#L106Â ↩
-
https://github.com/darkrenaissance/darkfi/blob/master/src/contract/money/proof/burn_v1.zk ↩
-
https://github.com/darkrenaissance/darkfi/tree/master/src/contract/money/tests ↩
-
https://github.com/darkrenaissance/darkfi/blob/master/src/contract/money/Makefile ↩
-
https://darkrenaissance.github.io/darkfi/misc/darkirc/darkirc.html ↩
-
https://darkrenaissance.github.io/darkfi/development/contribute.html?highlight=meeting#how-to-get-started ↩
-
https://darkrenaissance.github.io/darkfi/testnet/node.html ↩