Ok so here is my take on this assignment.
I actually made 3 contracts
1 with a mapping
1 with an array
and then another one with an array but with another implementation that did not exactly adhere to the requirements of the assignment.
The requirement was to only have 2 methods so the first array contract had too use loops when updating the entry.
The final array solution uses a little trick I though of.
For all of them to really see how the fees changed over time I ran both addEntry and updateEntry 10 times each (on 10 different accounts).
But lets start with the code of the mapping contract:
Mapping Contract
pragma solidity 0.7.4;
// Definition of contract with mapping storage
contract StorageDesignMapping {
// Define the struct
struct Entity{
uint data;
address _address;
}
// Define the mapping ofthe entities
mapping(address => Entity) Entities;
// Function to add / Set the data of a user
function addEntity(uint _data) public {
// Create a new object for the user with the given data
Entities[msg.sender] = Entity(_data, msg.sender);
}
// Function to update the data of a user
function updateEntity(uint _data) public {
// Checck so that the user already has some data
require(Entities[msg.sender]._address == msg.sender);
// Assign the new provided data withough overwriting anything that may be added in the future
Entities[msg.sender].data = _data;
}
}
Pretty straight forward…
Both the addEntry and the update entry are executed in constant time:
O(1)
Lets look at the fees:
Deployment
Transaction cost: 219 943
Execution cost: 124 771
Total cost: 344 714
AddEntry
Transaction cost: 62 726
Execution cost: 41 272
Total cost: 103 998
These numbers were the same for all the 10 transactions I made while testing.
UpdateEntry
Transaction cost: 27 766
Execution cost: 6 302
Total cost: 34 068
These numbers were the same for all the 10 transactions I made while testing.
Lets continue with the array contract
Array Contract
pragma solidity 0.7.4;
// Define the contract
contract StorageDesignArray {
// Define the entity struct
struct Entity{
uint data;
address _address;
}
// Define Entity array
Entity[] Entities;
// Function to add / Set the data of a user
function addEntity(uint _data) public {
// Declare empty entity
Entity memory entity;
// Assign values
entity.data = _data;
entity._address = msg.sender;
// Push the new entity to the array
Entities.push(entity);
}
// Function to update the data of a user
function updateEntity(uint _data) public returns (bool){
// Loop is used instead of a preknown index since the assignment required the contract to only have the add and update method.
// Therefore there is no way of knowing the index beforehand and a loop is needed and will cost a lot of gas
// Loop through the Entities
for(uint i = 0; i < Entities.length; i++){
// Continue if entry does not belong the the user
if (Entities[i]._address != msg.sender) continue;
// If we get here then the current entry belongs to the user so we set the data and return true
Entities[i].data = _data;
return true;
}
// If we get here then the user is not in the array so we return false. (We could also call addEntity with the data parameter to ensure that the data is added either way...)
return false;
}
}
Note that I tried to interpret the assignment strictly and only use the two specified methods.
This means that while the performance of the addEntry remains the same:
O(1)
, I have to use a loop in the update function to find the correct user which results in linear time:
O(n)
.
This means that for each entry the fees will increase more and more the newer the user.
So lets look at the fees:
Deployment
Transaction cost: 236 671
Execution cost: 137 783
Total cost: 374 454
This is an increase of 8,6% over the deployment of the mapping contract.
AddEntry
Transaction cost: 68 672
Execution cost: 47 208
Total cost: 115 880
While this cost was constant no matter how many times I made the transaction it turned out to be 11,42% more expensive than the mapping.
UpdateEntry
Call number 1
Call number 10
BOOM.
Turns out that for each entry that had to be searched an increase of about 25% from the previous index.
So we have the lowest transaction fee of: 26 107
And a high of : 49 885
And for the execution cost we have a low of 4643 and a high of 28 421.
that gives us a combined low of 30750 and a high of 78 306
And this was for only 10 entries. Imagine the cost of looping through an array of 1 000 000!!!
So the takeaway is that a search using loops is a no no…
Lets look at my final idea, although a bit hacky.
My idea
pragma solidity 0.7.4;
// Define the contract
contract StorageDesignArray2 {
// Define the entity struct
struct Entity{
uint data;
address _address;
}
// Define Entity array
Entity[] Entities;
// Function to add / Set the data of a user
function addEntity(uint _data) public {
// Declare empty entity
Entity memory entity;
// Assign values
entity.data = _data;
entity._address = msg.sender;
// Push the new entity to the array
Entities.push(entity);
}
// Function to update the data of a user
function updateEntity(uint _index, uint _data) public{
// Check so that the given index is within the range of the array and that the corresponding entry in fact belongs to the user
require((Entities.length > _index) && Entities[_index]._address == msg.sender, "Invalid user");
// Set the data
Entities[_index].data = _data;
}
// Function to retrieve the id (index) of the current user
// This should be a free call since it is both external and view
function getUserId() external view returns (uint){
// Loop through the array (we don't care that it costs performance since it is probably free anyways)
for(uint i = 0; i < Entities.length; i++){
// Continue of the entry does not match the user
if (Entities[i]._address != msg.sender) continue;
// If we get here then we found the id so we return the index
return i;
}
// Throw some kind of error to signal that the user was not found
revert("User not found");
}
}
So the only new thing here is that we have introduced a new function: getUserId, that is completely free to call (as long as the caller is not another contract) even if it uses loops since it is external and is a view function.
This way the front end can just make two calls, first a call to get the id (index) of the user. and then make a call to a updated updateEntry function that requires the index (from the first free call).
This results in a performance of constant time: O(1)
for the updateEntry even if the contract uses an array.
So lets compare the fees:
Deployment
Transaction cost: 331 585
Execution cost: 213 457
Total cost: 545 042
OK, so we have a much higher initial deployment cost. But hey, there is a third method here also so we are not comparing apples to apples here…
AddEntry
Transaction cost: 68 694
Execution cost: 47 230
Total cost: 115 924
So the cost is pretty much identical to the addEntry on the previous array contract (which is no surprise since the method is exactly the same)
UpdateEntry
So if we assume that the caller is not another contract and that the caller have previously called the getUserId function (for free) we get the following costs:
Transaction cost: 30 452
Execution cost: 8 796
Total cost: 39 248
This means that it is marginally more expensive per transaction than the mapping solution but have the advantage of later being able to have functions for calculating the sum of the data and so forth (for free) that could never be possible with the mapping solution.
Any thoughts on this?
Is it bad practice or just mean to use several free calls to get the needed data for the making calls that would otherwise be very expensive?