Solidity Structs¶
So far we relied on simple datatypes like addresses, uint or booleans. In the previous lecture we also grouped them together into structs and I showed you the difference between using smart contracts as data-stores vs structs as data-stores. But what if that's not enough and you need to track them in an array-like data-structure? Maybe you want to track the timestamp when a deposit happened. You want to track every single deposit from every user. You want to track how many deposits happened, and many more details.
Rule of Thumb
The Rule of Thumb should always be: Do only the most necessary functions on the blockchain, and everything else off-chain. But for sake of explaining Structs, we will track every single payment in the greatest detail possible with our Smart Contract.
For the sake of this example, think about it like this:
While you are tracking the current balance with a mapping that maps address to uint (mapping( address => uint ) balanceReceived
) with something schematically like balanceReceived[THE-ADDRESS] = THE-UINT
, with a struct you would access the children with a .
(period), while still retaining the mapping.
Let's say you have a struct
MyStruct {
uint abc;
}
mapping(address => MyStruct) someMapping;
Then you would write to the mapping like this someMapping[THE-ADDRESS].abc = THE-UINT
. Why is this useful? Let's have a look into our Smart Contract!
Add the following things to the Smart Contract:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.14;
contract MappingsStructExample {
struct Transaction {
uint amount;
uint timestamp;
}
struct Balance {
uint totalBalance;
uint numDeposits;
mapping(uint => Transaction) deposits;
uint numWithdrawals;
mapping(uint => Transaction) withdrawals;
}
mapping(address => Balance) public balanceReceived;
function getBalance(address _addr) public view returns(uint) {
return balanceReceived[_addr].totalBalance;
}
function depositMoney() public payable {
balanceReceived[msg.sender].totalBalance += msg.value;
Transaction memory deposit = Transaction(msg.value, block.timestamp);
balanceReceived[msg.sender].deposits[balanceReceived[msg.sender].numDeposits] = deposit;
balanceReceived[msg.sender].numDeposits++;
}
function withdrawMoney(address payable _to, uint _amount) public {
balanceReceived[msg.sender].totalBalance -= _amount; //reduce the balance by the amount ot withdraw
//record a new withdrawal
Transaction memory withdrawal = Transaction(msg.value, block.timestamp);
balanceReceived[msg.sender].withdrawals[balanceReceived[msg.sender].numWithdrawals] = withdrawals;
balanceReceived[msg.sender].numWithdrawals++;
//send the amount out.
_to.transfer(_amount);
}
}
It's getting quite extensive here, bear with me. Let's go through the Smart Contract together to understand what's going on here!
We have one struct called Transaction
, which stores the amount and the timestamp of any incoming/outgoing transaction. Then we have another struct called Balance
which stores the total balance and a mapping of all payments done.
Mapping has no Length
Mappings have no length. It's important to understand this. Arrays have a length, but, because how mappings are stored differently, they do not have a length.
Let's say you have a mapping mapping(uint => uint) myMapping
, then all elements myMapping[0]
, myMapping[1]
, myMapping[123123]
, ... are already initialized with the default value. If you map uint to uint, then you map key-type "uint" to value-type "uint".
Structs are initialized with their default value
Similar to anything else in Solidity, structs are initialized with their default value as well.
If you have a struct
struct Transaction {
uint amount;
uint timestamp;
}
mapping(uint => Transaction) myMapping
, then you can access already all possible uint keys with the default values. This would produce no error:
myMapping[0].amount
, or myMapping[123123].amount
, or myMapping[5555].timestamp
.
Similar, you can set any value for any mapping key:
myMapping[1].amount = 123
is perfectly fine.
So, with these two things in mind, structs allow you to define something similar like cheap objects.
Back to our Smart Contract.
Balance <-> Transaction relationship¶
If you have a look at the two structs, then you see there is also a mapping inside:
struct Transaction {
uint amount;
uint timestamp;
}
struct Balance {
uint totalBalance;
uint numDeposits;
mapping(uint => Transaction) deposits;
//...
}
mapping(address => Balance) public balanceReceived;
//...
Because mappings have no length, we can't do something like balanceReceived.length
or payments.length
. It's technically not possible. In order to store the length of the deposits mapping, we have an additional helper variable numDeposits
.
So, if you want to the first payment for address 0x123... you could address it like this: balanceReceived[0x123...].deposits[0].amount = ...
. But that would mean we have static keys for the payments mapping inside the Balance struct. We actually store the keys in numDeposits
, that would mean, the current payment is in balanceReceived[0x123...].numDeposits
. If we put this together, we can do balanceReceived[0x123...].deposits[balanceReceived[0x123...].numDeposits].amount = ...
.
Enough talking, let's give it a try!
Use the Smart Contract¶
- Deploy a new Instance of the Smart Contract.
- Then deposit 1 Ether with Account#1 (1 Ether -> sendMoney button)
- Then check the Balance
What you see is that you can retrieve the Balance easily, but you can't automatically get the "sub-mapping" (the mapping in the struct) value. You would have to write an extra getter-function, or you just use that mapping internally in your Smart Contract.
Of course, this is just an exemplary Smart Contract, but you will see later how we use mapping of mappings later in the Supply Chain Example.
On to the next Lab!