Moving funds
The custom tokens are fine and well, but let’s see how to interact with the native chain tokens and move them around.
We will create a simple vault contract. Users will be able to deposit native tokens to the contract and withdraw it back after some time (Exercise: come up with an explanation why we would want to do that and propose a reasonable lock time). The contract will have a deposit
and withdraw
function and mapping to keep track of all the deposits. But what to put in mapping? We need to keep track of the amount of tokens deposited and the time when the deposit was made for each user. We can either use two mappings or join multiple values into one - create a struct
. Let's go with the second option, which should make our code more readable.
Let’s declare a struct just before our new contract:
A struct in solidity is just a plain old data container that can hold multiple values of different types. Contrary to a C++ struct, it does not have any methods, but it can be used to store data in a more readable (and sometimes efficient) way. Returning structs from functions is possible and such functions are usually easier to work with than functions that return multiple values.
Before diving into code, let’s think about the logic of our contract. When a user deposits some tokens, we need to store the amount and the time of the deposit. When the user wants to withdraw, we need to check if the lock time has passed and if so, transfer the tokens to the user. We also have to check if the user has any tokens deposited and raise an error otherwise. What happens if the user that is depositing tokens has already deposited some tokens? We will just sum them up and reset the deposit time.
We use a private mapping to store the deposits. Since all values are implicitly zero-initialized, a user without the deposit will have a deposit of 0 (and a time of deposit as zero). We use msg.timestamp
throughout the contract to get the current time (this is the time in the header of the block that the transaction is included in). Since our use case is not very sensitive to time, we can use this approach, but in general, we have to be careful with time, as miners can manipulate time in the block header.
The withdraw function is pretty straightforward. We first check that the user has a deposit and if not, we raise an error. Then we check if the lock time has passed and if not, we raise an error, otherwise, we delete the deposit and transfer the tokens to the user. Before sending tokens to an address, we convert it to payable
to be able to send ether to it (this is just a type conversion, the address is not changed). We use transfer
instead of send
or call
to transfer the tokens, as it will revert if the transfer fails and to prevent any large calculations to be done after the transfer (this prevents any complicated smart contract to interact with our vault). We also first delete the deposit before to preemptively prevent reentrancy attacks (we will cover this in more detail in the next chapters).
The deposit
function only has a few specifics. It is marked payable
, so it can receive native tokens. The compiler inserts checks to make sure that functions without payable
modifiers can't receive the tokens to prevent errors. This is done to prevent users from mistakingly sending tokens to the functions that might improperly register the deposit, as such tokens might be lost forever. The most important part is the msg.value
variable. This is the amount of native tokens that were sent to the function (the function cant change this value and the tokens are immediately transferred to the contract address, so this address can potentially send them). We use this value to update the deposit and just reset the time of the deposit.
Two additional functions receive
and fallback
are also defined. They just forward the call to deposit
and are called when someone sends tokens directly to the contract address. They are defined as a fallback in case someone sends tokens to the contract address without specifying the function to call.
We denominate all our values in wei, which is the smallest unit of ether. Each ether is divided into 10¹⁸ wei (very small cents) and all calculations are done in integer numbers to avoid errors with floating point¹. This is why we use uint256
for all our values and the msg.value
is also in wei. We will use the same approach in the next chapter when calculating the correct price for the tokens.
Testing
Most of the tests are left as an exercise, but we will show how to deal with both time and funds in the tests. The first thing we need to do is to add some native tokens to our account, but how can we do that? Well, during tests, hardhat will automatically add funds to the accounts that it creates, so we can just use those (this sadly won’t be true on the real network). We can get the balance of the account using the web3.eth.getBalance
function and we can send tokens to the account using the sendTransaction
method.
Here are some example tests that showcase how to deal with time and funds. We first get the balance of the account before the transaction and after the transaction. We then check that the balance has changed and that the change is equal to the amount of tokens sent plus the gas costs (don’t worry if you don’t know how gas calculations work, yet).
The second test uses test-helpers
to increase the time (in this case, we just increase the time by the lock time and some more). The second test is also important, as it does not calls the deposit
function, but instead sends tokens directly to the contract address and therefore tests if forwarding works ok.
Reminder: We only tested for happy paths in this example, but we should also always test for unhappy paths (e.g. trying to withdraw before the lock time has passed).
Exercise: Write the missing tests, specifically, test for the unhappy path (when the user tries to withdraw before the lock time has passed, if the user does not have funds, how multiple resets work etc.).
Hurray!
We now have everything we need to develop a proper contract on the Flare network, join me in the next post, where we will really deploy our contract…
Other posts in this series:
[1]: Solidity has only rudimentary floating and fixed point number support, but even if it were to implement, it would be a bad idea to use it for financial applications. Floating point numbers are not precise and can lead to rounding errors both during calculations, storing, and displaying… just don’t.