Unlike other blockchain-based smart contract frameworks such as Ethereum, on Secret Network the program execution and data is encrypted and private, even from the nodes that are executing the contract. In this post I am going to relay my experience learning to code my first secret contract on Secret Network. In fact, it was the first smart contract I have written, so in the process I learned how to program in Rust as well as some of the peculiarities of writing smart (and secret) contracts. Hopefully, this post will benefit others who are excited about the prospect of what secret contracts can do, but don’t know quite where to start.
The contract I built lets you send Mission Impossible-style encrypted messages that “self-destruct” upon opening. Only the target of the message can read the message and once the message is read it is deleted from the contract storage.
The repo of the code is here: https://github.com/darwinzer0/secret-exploding-message
Part 1: Do a crash course in Rust.
Although Rust is much-loved by a number of programmers, it can be a difficult language to transition to quickly if you are used to languages that use garbage collection (i.e., most languages used by non-systems programmers these days). Having learned many languages over the years (procedural, functional, OO, etc.) I am used to the idea that I can scan code in a new language and sort out more or less what is happening even if the syntax is a little bit different. Rust confounded me in this regard, especially when it came to notions such as ownership and borrowing. The syntax was not “intuitive” and I found that I needed to spend a fair bit of time reading the language docs.
The Rust Book is a great place to start: https://doc.rust-lang.org/book/. Ownership is one of the most important new concepts in the language, and this post does a fine job of explaining it: https://depth-first.com/articles/2020/01/27/rust-ownership-by-example/. Once you feel comfortable with the basics of Rust you can move on to the secret contract tutorials.
Part 2: Working through the tutorials
I recommend that you start with the simple Figment learn tutorial for building and deploying secret contracts found here: https://learn.figment.io/network-documentation/secret/secret-pathway. However, the simple counter example used in those tutorials does not provide much insight into how to write more complex contracts. The next step is to look at the Secret Contracts Guide repository: https://github.com/enigmampc/secret-contracts-guide. The beginning of this guide is similar to the previous tutorial but it also explains how to set up a local testnet. The main new material can be found under the README.md section Secret Contracts 101.
The Secret Network repo is another good resource for general information about the network: https://github.com/enigmampc/SecretNetwork. In addition, it is very important to understand the privacy model of secret contracts: https://build.scrt.network/dev/privacy-model-of-secret-contracts.html. Beyond these tutorials and reference materials the next best thing is to dive right into some of the contracts that have already been built. One contract that was extremely helpful to me was the SCRT-sealed-bid-auction. The WALKTHROUGH.md file in that repo is a particularly valuable read.
Secret Network contracts are based on CosmWasm contracts. There are also a number of other resources on the web teaching you how to write CosmWasm contracts (just do a Google search). These tutorials can be useful for learning about how to write contracts in Rust, but it is important to keep in mind some differences how you would write a CosmWasm contract vs. a secret contract. A key one is that some of the data storage pattern libraries that have been developed for CosmWasm are not ideal for secret contracts. Specifically, they serialize data in JSON format, which can leak information about what data is changing because different field values will be stored as data of differing lengths (see privacy model above). Instead, we can use bincode2 serialization from the serialization package of the Secret Contract Development Toolkit (see the WALKTHROUGH.md file from above for more explanation).
Part 3: Making our Secret Exploding Message contract
Now I am going to assume that you have worked your way through some of the tutorials above, and are now ready to create a Secret Exploding¹ Message contract. Therefore, I am not going to go over all the basics, but rather I will describe how the code is structured and highlight some of the more interesting sticking points that I came across while trying to develop the contract.
I developed this contract using the IntelliJ IDEA IDE. Just install the plugin found here: https://intellij-rust.github.io. It sounds like VSCode is another popular option for Rust developers, though I have not used it.
¹See some comments about how self-destructive these messages actually are in the Disclaimer section below.
Storage
A naïve approach to storing messages is to put a vector of messages into storage for each address. However, writing, reading, and deleting data to storage on Secret Network costs gas, and reading and writing costs are dependent on the size of the data being accessed or written. This is defined by the Cosmos SDK as follows:
func KVGasConfig() GasConfig {
return GasConfig{
HasCost: 1000,
DeleteCost: 1000,
ReadCostFlat: 1000,
ReadCostPerByte: 3,
WriteCostFlat: 2000,
WriteCostPerByte: 30,
IterNextCostFlat: 30,
}
}
So instead we create two structs: Message
and MessageQueue
that implement a linked queue data structure in the storage. With this structure we only update at most two messages when a new message is sent. A Message has content
which is a byte vector representation of the message text, from
the canonical address of the sender, and ids for the prev
and next
message in the queue. The MessageQueue
stores the ids of the front
and rear
messages in the queue as well as its length. In addition a HashSet
of blocked addresses is also stored. Note, we store the canonical address of each blocked account as a Vec of bytes. To do this we can convert the Canonical address form via .as_slice().to_vec()
.
Messages and message queues are stored using a prefix-based storage method. The key for a Message is b"mes"
concatenated with a byte slice representation of the u128
message id (transformed using to_be_bytes()
). The key for a MessageQueue is b"box"
concatenated with byte slice representation of the Canonical address of the recipient as described in the previous paragraph.
pub struct Message {
pub content: Vec<u8>,
/// address of the sender
pub from: CanonicalAddr,
/// id of prev message, 0 means first in queue
pub prev: u128,
/// id of next message in queue, 0 means last in queue
pub next: u128,
}pub struct MessageQueue {
/// id of front message
pub front: u128,
/// id of end message
pub rear: u128,
/// length of queue
pub length: u32,
/// set of blocked addresses (canonical)
pub blocked: HashSet<Vec<u8>>,
}
Though implementing this queue data structure myself was a great learning experience, an alternative approach you could take to implement similar message queue behavior would be to use an AppendStore
storage wrapper (defined in the Secret Contract Development Toolkit) on top of a PrefixedStorage
. (If you decide to try that please share your solution in the comments below. Taking a look at the representation of transfer history in the reference SNIP-20 implementation would be a good starting point.)
In addition to these two structs, we also have a Config
struct stored with the key b"config"
and an auto incremented message sequence value (u128
) stored at b"seq"
. This is used to assign a unique identifier to each new Message
.
We also define two new wrapper structs: MessageStorage
and MessageQueueStorage
. These allow us to attach the storage to a prefixed version that provides some boilerplate code to easily read and modify Messages and Message Queues directly from our contract code.
Finally, four functions load
, save
, remove
, may_load
are defined which read or change the data in the storage using bincode2 serialization (this code is pulled directly from SCRT-sealed-bid-auction).
Messages
It is likely that you are making a request to your smart contract in the form of JSON data. Data that is serialized as JSON is limited in the kinds of information that can be encoded. Rust data types such as u128
will need to be sent as a string. In the JSON it will be a string value, in the Msg struct it will be a Uint128
(defined by CosmWasm), and in your contract code and in storage it will be a u128
. You do not need to do conversion from JSON string to Uint128 (that is handled by CosmWasm), but you do need to convert Uint128 variables using .to_u128()
. In addition, integer values in JSON correspond to i32
values in Rust. If you want your data to be of a different format, e.g. u16
, then you will likely need to cast to the appropriate type.
Initializing the contract
The initialization message for this contract takes the following format:
pub struct InitMsg {
/// initial value of the message id serial
pub seq_start: Uint128,
/// maximum number of messages per receiver address
pub max_messages: i32,
/// maximum size of a message in bytes
pub max_message_size: i32,
/// if discard true, will not push messages to a full queue,
/// else will dequeue oldest message to make room
pub discard: bool,
}
seq_start
is the starting id value for the first message. The id is incremented for each additional message that is sent. The max_messages
field must be 1
or higher. The max_message_size
is cast to a u16
, so must be in 1..65535
or will cause an error message.
We define five types of requests for the contract:
pub enum HandleMsg {
Send {
content: String,
target: HumanAddr,
},
Recv { },
Size { },
Block {
address: HumanAddr,
},
Unblock {
address: HumanAddr,
},
}
For responses the data field is sent as a padded binary Uint8Array
. This helps to obfuscate any information leaking about the message based on the response length. This is accomplished by adding the secret-toolkit
crate to the project. Add this line to your Cargo.toml
file:
secret-toolkit = { git = "https://github.com/enigmampc/secret-toolkit" }
And then we add this return line to the end of the handle
function in the contract (where BLOCK_SIZE
is a const usize
set at 256 bytes):
pad_handle_result(response, BLOCK_SIZE)
You will need to decode that result on the other end. For example, in your Javascript you would do something like the following (assuming the response
variable holds the response from the contract):
let utf8decoder = new TextDecoder();
let data = JSON.parse(utf8decoder.decode(response.data));
Sending messages
Messages are sent using the send
request with two parameters content
and target
. The message is added to the rear of the message queue for the target, unless: 1) the queue is full (#messages == max_messages
) and discard
was set to true
in the initialization message, or 2) the sender has been blocked by the recipient (see below).
The logic for handling a request message is found in the try_send
function.
Receiving messages
Receiving a message is done via a recv
request, rather than a query, because we want to have access to the sender's address, and we need to delete the message from storage.
As described above, the messages for each user are stored in a linked queue data structure in the storage. A request to receive a message dequeues the message at the front of the queue, deletes it from the storage, and returns the contents in the response. The number of remaining messages in the queue is also returned.
The logic for handling a receive request is found in the try_receive
function.
Getting count of messages in queue
The size
request is used to return the count of messages in queue without reading any message. It is defined in try_size
. I implemented size
as a request rather than a query so as to prevent others from seeing the size of another user’s message queue. However, I have since found out that it could be implemented as a query by using a Viewing Key system. Example code that shows how to implement viewing keys can be found in the reference SNIP-20 implementation.
Blocking and unblocking senders
Along with the message queue each user has a HashSet that holds the accounts that are blocked from sending messages. The block
and unblock
requests will modify the block list accordingly. These are defined in try_block
and try_unblock
respectively.
Disclaimer
I created this contract to help teach myself Rust and how to program secret contracts that run on Secret Network. Although privacy is baked into the network, no guarantees are made for how secret these messages actually are (e.g., due to data leaks, etc.). Results are padded using the secret-toolkit utilities, but I have not done an exhaustive evaluation of whether or how metadata such as key length, request message length, and message sending/receiving behavior on the network could leak information. You could ensure padding of messages from the client-side though there is no way for the contract to enforce this.
And an additional important caveat is that the message does not really “explode” entirely from the blockchain given that it still exists on the chain prior to the deletion (albeit in an encrypted form only accessible to the recipient). Secret network dev Reuven Podmazo shared that
…the receiver of the message could rerun the chain up until the point where the message was deleted, and request it again. That being said, it’s nontrivial, and deleting the message means that most won’t be able to see the message twice. … Practically, it is an exploding message as most users won’t be technically savvy enough to retrieve it, but … in theory it’s recoverable multiple times.
This post was updated on 13 Feb 2021 following feedback from some of the Secret Network dev team (many thanks especially to Reuven Podmazo). I have extended a few paragraphs with additional resources, and added more to the disclaimer at the end.