I've updated the first Liquidity article for betanet/zeronet. If you haven't read it yet, you should start there. Today, we'll review the contract we've written last time and try to figure out what's wrong with it. Then we'll make some improvements and talk about authentication.
The contract operates in three distinct phases. First, it waits until two players register with a hash of some number. Then the players have an opportunity to reveal the number itself, which is checked against the previously submitted hash. The numbers are combined and a winner is chosen.
After a closer look, you'll notice that the bet can be resolved only after both players have revealed their number. Nothing forces the players to actually reveal it -- each of the players can hold the bet hostage indefinitely, if they think they are going to lose. We can add a deadline and force the players to reveal a number or forfeit the bet.
We can use the
timestamp data type for keeping track of the deadline. The function
Current.time will give us the current time, just like
NOW in Michelson. To be precise, it actually gives us the timestamp of the block that would include our transaction. It is called with a unit argument, symbolized by
() in Liquidity.
Timestamps can be set with the RFC3339 short format and incremented with
+ and a number of seconds:
let someTime = 1970-01-01T00:00:00Z in let later = someTime + 60 in (* ... *)
When do we want to set the deadline and when do we want to reset it? Do we need to use it anywhere else? Try to add the deadline functionality. You can check my code below.
Making the contract reusable
The contract sends all the funds contained in it to the winner, so we can only use it once. To make it reusable, we can enforce a fee for playing -- by making sure the Register calls contain enough tez. In Michelson we have the
AMOUNT instruction and in Liquidity, we can get the amount of mutez in the transaction with
Current.amount, which takes a unit argument again.
Now both players are required to commit (at least) the same amount to the bet and the winner takes all.
It is possible that a winner will use a smart contract with a non-unit parameter and when we try to resolve the bet, our contract will fail. We can avoid this by checking whether the caller takes a unit argument during registration. The winner can still be a contract that fails and so stop our contract from resolving. This mostly hurts the winner, but we also waste the origination fee for originating a replacement contract (0.257 tez). A completely secure solution is to let participants submit public keys and send the winnings to implicit accounts derived from them (more on this below).
If you get stuck or want to compare solutions, here's my code implementing the additional features.
More than two players
What about a bet with more than two players? Can we just combine more numbers with
xor and choose the winner just like we do now? It turns out that with 3 people, the last player to reveal their number can see they are going to lose, and can sometimes decide the winner by not revealing their number. Since they are going to lose anyway, we have no further leverage to make them reveal it. This problem is present with any number of players higher than 2.
You are now familiar with some limitations of smart contracts, and in the rest of the article, we'll look at different ways to authenticate callers of our contracts.
Let's look at the different types of addresses in Tezos first. Generating new keys uses a random seed to create a secret key. From the secret key a public key is created, that can be used to verify data signed with the secret key. Going further, the public key is used to derive an address (so the address is also called a public key hash, PKH). The PKH starts with
tz and a number depending on the curve used to generate the keys. Besides the PKH, there are also contracts and accounts. Accounts are a special case of contracts -- they have no code and are always spendable. Contract addresses start with
If we want to make sure that only a PKH address can be used in your contract, you should ask the caller to provide a public key and use the
IMPLICIT_ACCOUNT instruction to derive the address. Since the PKH is derived from a public key and by extension from its secret key, this will rule out smart contracts.
When we don't need to exclude smart contracts, we have a few choices. We can use
SOURCE in Michelson) to get the address which started the current transaction sequence. This will be a PKH or a spendable contract. Note that using
SOURCE to authenticate users is dangerous -- users can be conned into calling a malicious contract, which then calls our contract and acts on their behalf. On one hand, users should make sure they understand the contracts they are calling, but it's almost a certainty that majority of users won't audit the contracts they use.
Luckily, we can use
SENDER in Michelson), which gives us the previous address in the current transaction sequence. This way we can avoid the issue completely, and our contract will also be compatible with smart contract callers, e.g. multi-sig wallets.
Another approach is to use signatures. This requires holding a public key in our contract, and verifying that incoming calls were signed by the corresponding private key. Any address can be a source of such transaction, as long as the signature checks out. We should make sure the signature can't be reused, either by using a counter or otherwise making it useless to re-transmit the old data and signatures.
If you have any feature requests or suggestions towards the Liquidity team, the project's git is here.
It's still early days for Tezos, so things aren't standardized yet. Come to the developer channel and discuss your use case and what the best approach is.
To get notified about my articles, you can subscribe to my email list.