Table of contents
- The Logic of the Contracts
- How to Build a Savings Timelock Contract with Solidity [Step-by-step]
- Step 1: State Variables
- Step 2: Mappings
- Step 3: Constructor and Modifiers
- Step 4: Function to Join
- Step 5: Deposit into Savings Timelock
- Step 6: The Withdrawal Function
- Step 7: The Premature Withdrawal Function
- Step 8: Saving Extension Function
- Step 9: The Leave Function
- Step 10: Total Users Function
- Step 11: Total Contract Balance
- Building a Spend-and-Save Contract
- How to Build on Linea
- Testing and Deployment
Anyone who wants to build sustainable wealth must be interested in personal finance; the art and science of managing your money. This is something I have been interested in for a long time.
Now that I have some free time off my sleeve, I am thinking of creating smart contracts for various use cases around personal finance.
In this guide, I will be showing you the contracts I wrote, and also teach you how you can build something like that too; perhaps better ones.
I hope to turn this to a series of blog so I can be showing you how I am adding new functionalities, refactoring the codebase, considering security issues, and what I am learning along the way.
The Logic of the Contracts
Subsequently, I will be showing you how to build two smart contracts.
The first one is a thrift timelock contract where users will deposit money and cannot withdraw until the timeline is over.
To be practical, I added a fast withdrawal function for urgency, only that they will have to pay some percentage of the funds as a punishment.
Then they can also choose to extend the timeline for their savings. Basically, the goal behind this smart contract is to help people more with discipline in saving their funds for a goal, such as wedding, gadget, or even founding a company - like me.
Moving to the second contract, it is for people to save some percentage of their funds as they spend. Imagine having $1k USDC in a wallet, then when you spend $100 USDC, $30 USDC is moved from your wallet and saved in a separate wallet.
This is a feature in one of the Web2 personal finance apps I use, and I love it so much. Hence, why I am building something like that.
Now, that you know the scope of what we are building, let’s write the code.
How to Build a Savings Timelock Contract with Solidity [Step-by-step]
It is better to write this contract with a Foundry instance, but you can use Remix IDE for the sake of simplicity.
Step 1: State Variables
We need to create a dataset for the users and also some peculiar variables for the contract.
pragma solidity 0.8.19;
contract MoneyClub {
struct Users {
string name;
string savingGoal;
address userAddress;
}
// this is the address of the deployer
address private applicationAdmin;
address[] private userAddressArray;
// this is the normal duration
uint constant savingsSpan = 100 days;
uint private theBeginning = block.timestamp;
// the fee to pay for urgentWithdrawal, created at state, implemented locally
uint private penaltyFeePercentage = 10;
// against reentrancy
bool private checkmate;
As you can see, the name of the contract is MoneyClub
. We created a struct called Users
to keep the data of everyone who interacts with the contract. We want to collect only their names, their goals for using the application, and their wallet addresses.
Then we created a variable for the team behind the contract. This address will be the deployment address as well as where the locked funds will be.
userAddressArray
is a variable for us to reference the array of the addressessavingSpan
is the duration of the period everyone wants to save for, which is 100 days.beginningTime
is the actualblock.timestamp
which is the period the contract becomes active on the blockchain.penaltyFeePercentage
is how much people will have to pay if they want to urgently withdraw when the timeline is still on.checkmate
will be more useful later as we will use it for a reentrancy lock.
Step 2: Mappings
This is a contract where we will need to track and reference a lot of actions. Hence, mappings will be useful for our purpose.
There are other design methods to avoid this use of many mappings, but I prefer this for both engineering and security reasons.
mapping(address => Users) private usersMap;
mapping (address => uint) private balancesMap;
mapping (address => bool) private registeredUsers;
mapping (address => uint) private registrationTime;
We have four mappings in the contract.
Recall that we created the
Users
struct above, theusersMap
mapping is used to call this struct entirely.we mapped the addresses of users to
uint
, a data type for numbers, to track the balances and cause changes to it.We used boolean to ascertain the registered users.
Then we used
registrationTime
to track timing.
Step 3: Constructor and Modifiers
We will have to initialize the address of the deployer. Generally, this might not be important, but it is for this contract.
constructor () {
applicationAdmin = payable(msg.sender);
}
// modifiers
modifier onlyOwner () {
require(msg.sender == applicationAdmin);
_;
}
modifier onlyRegisteredUsers () {
require(registeredUsers[msg.sender], "only registered users can call this function");
_;
}
modifier checkmateReentrancy () {
require(!checkmate, "you cannot reenter this function");
checkmate = true;
_;
checkmate = false;
}
We set the address that deployed the contract to be the admin.
The
onlyOwner
modifier equates themsg.sender
to theapplicationAdmin
; meaning that only the admin can call those contracts exclusively.The
onlyRegisteredUsers
modifier ensures people who are not users cannot call functions at all. This is necessary for elementary DoS and other security considerationscheckmateReentrancy
makes it impossible to break the withdrawal logic in an attempt to withdraw twice. We utilized the booleancheckmate
variable at the state for this.
Step 4: Function to Join
This is the function people will call to join the money club. They will have to enter their names, goals, and addresses and compulsory parameters.
// people can join the club, and also provide their particulars
function joinMoneyClub (string memory _name, string memory _savingGoal, address _userAddress) public{
require(!registeredUsers[msg.sender], "error - this address has registered once and for all");
require(msg.sender != address(0), "error - zero addresses unallowed");
usersMap[msg.sender] = Users(_name, _savingGoal, _userAddress);
registeredUsers[msg.sender] = true;
registrationTime[msg.sender] = block.timestamp;
}
There is a check to ensure that those who have registered cannot register again with the same details for a saving span; this is to prevent double registration
Another check is for checkmating zero addresses.
Once they have registered, their
registeredUsers
mapping will be set to true; recall that the default for bools is false.Then their time of registration begins immediately after they have entered their details.
Step 5: Deposit into Savings Timelock
People can deposit money to be locked once they have registered.
function depositMoneyToLock () public payable onlyRegisteredUsers{
require(msg.value > 0, "you must have more than 0.1 Ether to deposit");
balancesMap[msg.sender] += msg.value;
}
Whatever the user tries to deposit is added to the msg.value
, the amount of tokens, under their address.
We also added a modifier to ensure only those who have registered can deposit.
Step 6: The Withdrawal Function
First of all, no one can withdraw unless the 100 days are over, and only those who have deposited can withdraw.
function withdrawDueMoney (address withdrawalDestinationAddress) public payable onlyRegisteredUsers checkmateReentrancy
{
require(address(this).balance > 0, "no money to withdraw");
require(block.timestamp <= registrationTime[msg.sender] + theBeginning + savingsSpan, "the saving period has not started");
(bool successful, bytes memory data) = withdrawalDestinationAddress.call{value: msg.value}("");
require(successful, "Couldn't successfully withdraw - error");
}
We made a transfer call to a withdrawalDestinationAddress
which must be provided; this is where the user will withdraw to.
Step 7: The Premature Withdrawal Function
I use personal finance apps as a person. And one thing I don’t appreciate is how some of them don’t allow urgent withdrawal.
Indeed, there can be real-life situations that can demand it. So I added the function.
But bear in mind that we should not make this function a frivolous one so the goal of discipline will not be lost.
This was why I designed that anyone who wants to make urgent withdrawal will have to part with 10% of the funds.
This will make people only call the function when they think it is worth it.
function urgentWithdrawal (uint amountToWithdraw, address withdrawalDestinationAddress) public payable onlyRegisteredUsers checkmateReentrancy
{
require(block.timestamp <= registrationTime[msg.sender] + theBeginning + savingsSpan, "the saving period has not started");
uint penaltyFee = (amountToWithdraw * penaltyFeePercentage) / 100;
uint withdrawableAmount = amountToWithdraw - penaltyFee;
// we cannot make two low-level calls
// hence, we pay the admin with an old transfer call
// and the user with the actual low-level call
payable(applicationAdmin).transfer(penaltyFee);
(bool successful, bytes memory urgentWithdrawalStatus) = withdrawalDestinationAddress.call{value: withdrawableAmount}("");
require(successful, "Couldn't successfully withdraw - error");
}
We made two transfers here:
one to transfer the
penaltyFee
to the admin, andanother where the user can withdraw the 90%.
Step 8: Saving Extension Function
Anyone can decide to save for a longer period, and we have to make a provision for that. Hence, this function.
function extendSavingPeriod (uint spanExtension) public view onlyRegisteredUsers {
uint extraTime = spanExtension * 30 days;
extraTime += registrationTime[msg.sender] + theBeginning + savingsSpan;
}
They can input the number of months they want to extend for, and the contract will multiply it by 30 days.
So this extraTime
is added to the existing span.
Step 9: The Leave Function
There are two main reason for this function.
The first one is if the condition precedent to the contract is met. That is, if someone is saving for a wedding, once they have saved for the timeline and withdrawn, they might have no other reason to stay, and then leave.
The second is because they should have the right to.
function leaveClub (address _putYourAddress) public onlyRegisteredUsers{
delete usersMap[_putYourAddress];
}
All a user needs to do for this is to input their address for it to be deleted from the array.
Step 10: Total Users Function
The admin should be able to know how many users they are currently servicing.
function totalNumberOfUsers () public view onlyOwner returns (uint){
// the best option is to for loop it out
return userAddressArray.length;
}
So we will get the length of the userAddressArray
. This will return the number of addresses that have entered the contract.
Step 11: Total Contract Balance
How can the team know how much exactly they have under custody? First, they can know this by checking the wallet they used for deployment. Secondly, there should be a function to call.
function knowToTalDepositedMoney () public view onlyOwner returns (uint) {
return address(this).balance;
}
This function gets all the balances in the contract.
Step 12: Function for Specific User Balance
A user can simply request to know how much they have in the contract, maybe to be sure.
function knowTotalPersonalDeposit (address forMe) public view onlyRegisteredUsers returns (uint) {
return balancesMap[forMe];
}
They will input their address here, and their balance will be returned.
This is the full code:
pragma solidity 0.8.19;
contract MoneyClub {
// this is the address of the deployer
address private applicationAdmin;
struct Users {
string name;
string savingGoal;
address userAddress;
}
address[] private userAddressArray;
// this is the normal duration
uint constant savingsSpan = 100 days;
uint private theBeginning = block.timestamp;
// the fee to pay for urgentWithdrawal, created at state, implemented locally
uint private penaltyFeePercentage = 10;
// against reentrancy
bool private checkmate;
// map to track balances
mapping(address => Users) private usersMap;
mapping (address => uint) private balancesMap;
mapping (address => bool) private registeredUsers;
mapping (address => uint) private registrationTime;
constructor () {
applicationAdmin = payable(msg.sender);
}
// modifiers
modifier onlyOwner () {
require(msg.sender == applicationAdmin);
_;
}
modifier onlyRegisteredUsers () {
require(registeredUsers[msg.sender], "only registered users can call this function");
_;
}
modifier checkmateReentrancy () {
require(!checkmate, "you cannot reenter this function");
checkmate = true;
_;
checkmate = false;
}
// people can join the club, and also provide their particulars
function joinMoneyClub (string memory _name, string memory _savingGoal, address _userAddress) public{
require(!registeredUsers[msg.sender], "error - this address has registered once and for all");
require(msg.sender != address(0), "error - zero addresses unallowed");
usersMap[msg.sender] = Users(_name, _savingGoal, _userAddress);
registeredUsers[msg.sender] = true;
registrationTime[msg.sender] = block.timestamp;
}
// this is more like a receive fallback function, but we added balancesMap for easier tracking of
// those who deposit money
// of course, only those who have joined can deposit
function depositMoneyToLock () public payable onlyRegisteredUsers{
require(msg.value > 0, "you must have more than 0.1 Ether to deposit");
balancesMap[msg.sender] += msg.value;
}
// this is for withdrawal after the saving period ends
function withdrawDueMoney (address withdrawalDestinationAddress) public payable onlyRegisteredUsers checkmateReentrancy
{
require(address(this).balance > 0, "no money to withdraw");
require(block.timestamp <= registrationTime[msg.sender] + theBeginning + savingsSpan, "the saving period has not started");
(bool successful, bytes memory data) = withdrawalDestinationAddress.call{value: msg.value}("");
require(successful, "Couldn't successfully withdraw - error");
}
// this is for premature withdrawal
function urgentWithdrawal (uint amountToWithdraw, address withdrawalDestinationAddress) public payable onlyRegisteredUsers checkmateReentrancy
{
require(block.timestamp <= registrationTime[msg.sender] + theBeginning + savingsSpan, "the saving period has not started");
uint penaltyFee = (amountToWithdraw * penaltyFeePercentage) / 100;
uint withdrawableAmount = amountToWithdraw - penaltyFee;
// we cannot make two low-level calls
// hence, we pay the admin with an old transfer call
// and the user with the actual low-level call
payable(applicationAdmin).transfer(penaltyFee);
(bool successful, bytes memory urgentWithdrawalStatus) = withdrawalDestinationAddress.call{value: withdrawableAmount}("");
require(successful, "Couldn't successfully withdraw - error");
}
// for users who want to keep saving and not withdraw
// they can add the extra span they want to save for
function extendSavingPeriod (uint spanExtension) public view onlyRegisteredUsers {
uint extraTime = spanExtension * 30 days;
extraTime += registrationTime[msg.sender] + theBeginning + savingsSpan;
}
// technically, we push leaving members to the end of the array
// and remove with ".pop"
function leaveClub (address _putYourAddress) public onlyRegisteredUsers{
delete usersMap[_putYourAddress];
}
// to get the total number of users
function totalNumberOfUsers () public view onlyOwner returns (uint){
// the best option is to for loop it out
return userAddressArray.length;
}
// we get to know how much everyone has deposited
function knowToTalDepositedMoney () public view onlyOwner returns (uint) {
return address(this).balance;
}
// to ascertain personal deposit
// user will have to provide address
function knowTotalPersonalDeposit (address forMe) public view onlyRegisteredUsers returns (uint) {
return balancesMap[forMe];
}
}
Building a Spend-and-Save Contract
We have walked through the first contract above. Now, it is time to check through the spend and save contract.
You might wonder, why didn’t we write everything in one place?
There would have been three withdrawal functions, one more struct for this purpose, and another deposit function.
Well, it is better to make our code neat by separating vital features. This is why I made this a separate contract.
The main difference in this contract is the one the function to execute spend-and-save.
To this end, we had to create some state variables.
Step 1: New State Variables
Here are the state variables we introduced:
address private applicationAdmin;
address private yourDisciplineAddress;
uint private PercentageToSave = 20;
This time around, they shouldn’t send what they save at every spending to the admin, it should still be the users’.
So we created a yourDisciplineAddress
variable.
Then we also made a variable for the percentage to save, which is 20%.
Step 2: Set Discipline Wallet
Discipline wallet is where the percentages of savings go.
function setDisciplineWallet (address _disciplineWallet) public view onlyRegisteredUsers{
_disciplineWallet = yourDisciplineAddress;
}
Users will have to input a wallet address to this function.
Step 3: The Spend-to-save Function
This is the main function of this contract. We had to create two local variables: remainingMoney
and toBeSaved
.
The former is the balance after deducting the savings percentage. The latter is the 20% of the money that is withdrawn.
function spendToSave (uint amountToWithdraw, address destinationAddress) public payable checkmateReentrancy onlyRegisteredUsers{
uint remainingMoney;
uint toBeSaved;
toBeSaved = amountToWithdraw * PercentageToSave / 100;
remainingMoney = amountToWithdraw - toBeSaved;
payable(yourDisciplineAddress).transfer(toBeSaved);
payable(destinationAddress).transfer(remainingMoney);
}
The users will have to input how much they want to withdraw, and also specify the address they are paying to.
The rationale behind this is that people will be able to save a cut of what they spend on groceries they order from the malls around - assuming the malls provide addresses for crypto payments.
Here is the full code:
pragma solidity 0.8.24;
contract spendAndSave {
struct Users {
string name;
address userAddress;
}
address private applicationAdmin;
address private yourDisciplineAddress;
uint private PercentageToSave = 20;
mapping (address => uint) private spendingMap;
mapping(address => Users) private usersMap;
mapping (address => bool) private registeredUsers;
bool internal checkmate;
modifier checkmateReentrancy () {
require(!checkmate, "you cannot reenter this function");
checkmate = true;
_;
checkmate = false;
}
modifier onlyRegisteredUsers () {
require(registeredUsers[msg.sender], "only registered users can call this function");
_;
}
function joinMoneyClub (string memory _name, address _userAddress) public{
require(!registeredUsers[msg.sender], "error - this address has registered once and for all");
require(msg.sender != address(0), "error - zero addresses unallowed");
usersMap[msg.sender] = Users(_name, _userAddress);
registeredUsers[msg.sender] = true;
}
function setDisciplineWallet (address _disciplineWallet) public view onlyRegisteredUsers{
_disciplineWallet = yourDisciplineAddress;
}
function depositMoney () public payable onlyRegisteredUsers {
require(msg.value > 0, "you must have more than 0.1 Ether to deposit");
spendingMap[msg.sender] += msg.value;
}
function spendToSave (uint amountToWithdraw, address destinationAddress) public payable checkmateReentrancy onlyRegisteredUsers{
uint remainingMoney;
uint toBeSaved;
toBeSaved = amountToWithdraw * PercentageToSave / 100;
remainingMoney = amountToWithdraw - toBeSaved;
payable(yourDisciplineAddress).transfer(toBeSaved);
payable(destinationAddress).transfer(remainingMoney);
}
}
How to Build on Linea
The intent is to deploy these contracts on the Linea blockchain, so we will have to take steps in that direction.
The best way to do this is to use your MetaMask. Linea is one of the default blockchains under change network
.
Particularly, change your MetaMask network to Linea Sepolia.
Then go ahead to get some faucets from Covalent. That makes you ready to go.
Testing and Deployment
We need to test these contracts for two reasons: to ensure the functions are working, and to test against various attack vectors.
The best method to do this is to test with Foundry. I plan to do this, but I’m still working on my Foundry testing skills.
Expect more of my Foundry testing tutorials, and particularly the test for these contracts in the future.
But for now, we will simply test with Remix. Again, testing with Remix is not professional enough, and you will miss a lot of performance and security bugs, but we will only use it for now and for the sake of convenience.
I have recorded a video on my YouTube for the testing and deployment on my YouTube - that’s much better and faster.
Head over here to learn how to test and deploy these contracts on Linea.