Hello friends, In this post, we will implement the Fixed Deposit feature in our bank smart contract using solidity, which will help you learn how we can interact with one smart contract with another, and implement features based on time. Fixed deposit is a feature where we lock some amount for a particular span of time say 1 year then after 1 year if you withdraw then you will get an amount incremented with some interest as set by our bank contract. So we will develop the smart contract for it.
As this is the continuation of our previously built smart contract, please refer to the previous post where we built a bank smart contract using solidity.
Understand Re-entrancy attack by building a Bank Smart Contract in Solidity
Requirements
For storing FD(Fixed Deposit) we need a custom data type so we need a struct to store the deposit amount, no. of years, lock time or minimum time amount need to hold, claim amount, fixed deposit status and we use mapping to map account address to this struct. For status, we have an enum, for interest rate and penalty rate we need uint, and finally variable of type Bank contract to create an instance of it. We have functions to open an FD, check for returns, request, approve and withdraw claims, and finally, we have setters and getters for interest rate & penalty rate. Also, need a getter for FD details.
Lets code now
Before starting with the FixedDeposit contract we need some changes to our Bank contract. We will update our accountBalance with a new argument account address and we will change msg.sender to account, and similar changes for the accountStatus function the reason why we are changing is when we call this function from FixedDeposit contract msg.sender will be contract address instead of user address calling the function. Also, we need a new function in which we need to deposit the amount only from our FD contract to the Bank contract once the user withdraws their return amount, so to make sure only the FD contract can call this function we have a new variable and setter function to store the address of FD contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Bank{
...
address fdContract;
...
function setFDContract(address _fdAddress) external onlyOwner {
fdContract = _fdAddress;
}
function depositFDAmount(address account) external payable {
require(msg.sender == fdContract, "Only FD contract can call this");
balances[account] += msg.value;
}
function getAccountStatus(address account) public view returns (Status) {
return accountStatus[account];
}
function accountBalance(address account) public view returns (uint) {
require(
accountStatus[account] == Status.ACTIVE,
"accountBalance(): Account not active"
);
return balances[account];
}
function getFDContractAddress() public view returns (address) {
return fdContract;
}
...
}
The first line would license identifier, next we mention the solidity version to use and we will import Bank.sol as we need to call functions of that contract. Define the contract with the name FixedDeposit, and declare all variables we need.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "./Bank.sol";
contract FixedDeposit {
enum FDStatus {
NOT_REQUESTED,
STARTED,
CLAIM_REQUESTED,
CLAIM_APPROVED,
CLOSED
}
struct FD {
uint256 depositedAmount;
uint256 noOfYears;
uint256 lockedtime;
uint256 claimAmount;
FDStatus status;
}
mapping(address => FD) public fds;
uint256 public interestRate = 5;
uint256 public penaltyRate = 1;
Bank bank;
}
We have multiple conditions which need to be checked before performing operations let's define them as modifiers. For opening an FD, we have the following conditions to be met such as account status should be active, FD should be in NOT_REQUESTED or CLOSED state, the number of years should be in between 1–10 years, the deposit amount should be in 1 ether for this modifier we will pass deposit amount and a number of years. We also need a modifier to check whether FD status is REQUESTED before the user can call to open an fd again because at a time only one FD can be opened, one more for checking whether the sender is a bank owner or not. Also, check whether FD is in claim status.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "./Bank.sol";
contract FixedDeposit {
...
modifier isEligibleForFD(uint256 amount, uint256 noOfYears) {
require(
bank.getAccountStatus(msg.sender) == Bank.Status.ACTIVE,
"openFD: Account not opened/active"
);
require(
fds[msg.sender].status == FDStatus.NOT_REQUESTED ||
fds[msg.sender].status == FDStatus.CLOSED,
"openFD: FD already opened"
);
require(
msg.value == amount,
"openFD: Deposited amount and entered amount does not match"
);
require(
msg.value >= 1 ether,
"openFD: Minimum amount to deposit for FD is 1 ether"
);
require(
noOfYears <= 10 && noOfYears >= 1,
"openFD: noOfYears should be between 1-10 years"
);
_;
}
modifier isFDStarted() {
require(
fds[msg.sender].status == FDStatus.STARTED,
"isFDStarted() FD not started"
);
_;
}
modifier isFDNotStarted() {
require(
fds[msg.sender].status != FDStatus.STARTED,
"isFDNotStarted: FD in progress"
);
_;
}
modifier onlyOwner() {
require(
bank.getOwner() == msg.sender,
"onlyOwner: Only owner can perform this operation"
);
_;
}
modifier canApproveClaim(address account) {
require(
fds[account].status == FDStatus.CLAIM_REQUESTED,
"canApproveClaim: Claim request does not exists"
);
_;
}
}
In our constructor, we have one argument of bank contract address which we will use to instantiate the bank contract. For the open FD payable function, we will pass amount and noOfYears as an argument checking a condition using modifiers such as iseligibleForFD and isFDNotStarted if the condition satisfies then we will create a new FD with the given amount, noOfYears, for lockTime we will assign value as the current block.timestamp + (noOfYears * 31556926), block.timestamp which will return current timestamp in seconds epoch. In below website we can find more detail on it.
To check the current returns if we claim we have a view function here we will check if block.timestamp is less than fd lock time we will calculate and return the amount by subtracting the deposited amount from the penalty rate, else we will return the deposited amount + simple interest calculated. To raise a claim request we can check if the FD status is in REQUESTED and change the status to CLAIM_REQUESTED and the bank owner can approve if the status is CLAIM_REQUESTED state and set claim amount to the value calculated using the check returns function. For withdraw function we can call the depositFDAmount method and transfer ethers from the FD contract to our bank contract and update the balances in both contracts. Finally, we have getters setters for interest and penalty rates.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "./Bank.sol";
contract FixedDeposit {
enum FDStatus {
NOT_REQUESTED,
STARTED,
CLAIM_REQUESTED,
CLAIM_APPROVED,
CLOSED
}
struct FD {
uint256 depositedAmount;
uint256 noOfYears;
uint256 lockTime;
uint256 claimAmount;
FDStatus status;
}
mapping(address => FD) public fds;
uint256 public interestRate = 5;
uint256 public penaltyRate = 1;
Bank bank;
modifier isEligibleForFD(uint256 amount, uint256 noOfYears) {
require(
bank.getAccountStatus(msg.sender) == Bank.Status.ACTIVE,
"openFD: Account not opened/active"
);
require(
fds[msg.sender].status == FDStatus.NOT_REQUESTED ||
fds[msg.sender].status == FDStatus.CLOSED,
"openFD: FD already opened"
);
require(
msg.value == amount,
"openFD: Deposited amount and entered amount does not match"
);
require(
msg.value >= 1 ether,
"openFD: Minimum amount to deposit for FD is 1 ether"
);
require(
noOfYears <= 10 && noOfYears >= 1,
"openFD: noOfYears should be between 1-10 years"
);
_;
}
modifier isFDStarted() {
require(
fds[msg.sender].status == FDStatus.STARTED,
"isFDStarted() FD not started"
);
_;
}
modifier isFDNotStarted() {
require(
fds[msg.sender].status != FDStatus.STARTED,
"isFDNotStarted: FD in progress"
);
_;
}
modifier onlyOwner() {
require(
bank.getOwner() == msg.sender,
"onlyOwner: Only owner can perform this operation"
);
_;
}
modifier canApproveClaim(address account) {
require(
fds[account].status == FDStatus.CLAIM_REQUESTED,
"canApproveClaim: Claim request does not exists"
);
_;
}
constructor(address _bankAddress) {
bank = Bank(_bankAddress);
}
function openFD(
uint256 amount,
uint256 noOfYears
) public payable isEligibleForFD(amount, noOfYears) isFDNotStarted {
fds[msg.sender] = FD(
amount,
noOfYears,
block.timestamp + (noOfYears * 31556926),
0,
FDStatus.STARTED
);
}
function checkReturns() public view returns (uint256) {
FD memory data = fds[msg.sender];
if (block.timestamp < data.lockTime) {
return (data.depositedAmount * (100 - penaltyRate)) / 100;
} else {
return
data.depositedAmount +
((data.depositedAmount * data.noOfYears * interestRate) / 100);
}
}
function requestClaim() public isFDStarted {
fds[msg.sender].status = FDStatus.CLAIM_REQUESTED;
}
function approveClaim(
address account
) public onlyOwner canApproveClaim(account) {
fds[account].status = FDStatus.CLAIM_APPROVED;
fds[account].claimAmount = checkReturns();
}
function withdrawClaim() external {
FD memory fd = fds[msg.sender];
require(fd.status == FDStatus.CLAIM_APPROVED);
fds[msg.sender].status = FDStatus.CLOSED;
fds[msg.sender].claimAmount = 0;
bank.depositFDAmount{value: fd.claimAmount}(msg.sender);
}
function getInterestRate() public view returns (uint256) {
return interestRate;
}
function setInterestRate(uint256 _interestRate) public onlyOwner {
interestRate = _interestRate;
}
function getPenaltyRate() public view returns (uint256) {
return penaltyRate;
}
function setPenaltyRate(uint256 _penaltyRate) public onlyOwner {
penaltyRate = _penaltyRate;
}
}
The next part is testing we can use Remix IDE to run our code and check it's working. We can also write unit testing for the contract and check whether it is working as expected. First, let’s deploy here we will learn one thing new here previously we can see that once we run npx hardhat deploy all files inside the deploy folder will be executed sometimes we don’t want all to be deployed so hardhat helps in adding tags to the script so while deploying we can mention tags and script will be executed if tag matches.
./smart-contracts/deploy/01-bank-deploy.js
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deploy, log } = deployments;
const { deployer } = await getNamedAccounts();
await deploy("Bank", {
from: deployer,
args: [],
log: true,
waitConfirmations: 1,
});
};
module.exports.tags = ["bank"];
./smart-contracts/deploy/03-fd-deploy.js
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deploy, log } = deployments;
const { deployer } = await getNamedAccounts();
const bank = await ethers.getContract("Bank");
await deploy("FixedDeposit", {
from: deployer,
args: [bank.address],
log: true,
waitConfirmations: 1,
});
};
module.exports.tags = ["fd"];
npx hardhat deploy — tags bank,fd
For testing, we will chai again and similarly prepare test cases as done for bank smart contract, and for testing purposes modify the value 31556926(1 year) to 60 seconds in openFD function. We also need a function to increase the block time value during the tests, for this hardhat comes with a network functions where we can increase time followed by mine.
network.provider.send("evm_increaseTime", [60 * 3]);
network.provider.request({ method: "evm_mine", params: [] });
./smart-contracts/test/03-fd.test.js
const { expect, assert } = require("chai");
const { ethers, deployments, network } = require("hardhat");
describe("FixedDeposit", () => {
let accounts, bank, fd, fd1;
beforeEach(async () => {
accounts = await ethers.getSigners();
await deployments.fixture();
bank = await ethers.getContract("Bank");
fd = await ethers.getContract("FixedDeposit");
fd1 = await fd.connect(accounts[1]);
let tx = await bank.openAccount();
await tx.wait();
let tx1 = await bank.approveAccount(accounts[0].address);
await tx1.wait();
let tx2 = await bank.setFDContract(fd.address);
await tx2.wait();
});
describe("Starting Test", () => {
it("Open FD fails if not eligible conditions or already stared and open fd success if eleigible", async () => {
await expect(
fd.openFD(ethers.utils.parseEther("1"), 9),
"openFD: Deposited amount and entered amount does not match"
);
await expect(
fd.openFD(ethers.utils.parseEther("0.9"), 10, {
value: ethers.utils.parseEther("0.9"),
}),
"openFD: Minimum amount to deposit for FD is 1 ether"
);
await expect(
fd.openFD(ethers.utils.parseEther("1"), 11, {
value: ethers.utils.parseEther("1"),
}),
"openFD: noOfYears should be between 1-10 years"
);
await expect(
fd1.openFD(ethers.utils.parseEther("1"), 10, {
value: ethers.utils.parseEther("1"),
}),
"openFD: Account not opened/active"
);
let tx = await fd.openFD(ethers.utils.parseEther("1"), 2, {
value: ethers.utils.parseEther("1"),
});
await tx.wait();
await expect(
fd.openFD(ethers.utils.parseEther("1"), 10, {
value: ethers.utils.parseEther("1"),
}),
"openFD: FD already opened"
);
assert.equal(
(await fd.getFD(accounts[0].address)).depositedAmount.toString(),
ethers.utils.parseEther("1").toString()
);
assert.equal((await fd.getFD(accounts[0].address)).status, 1);
});
it("Check returns ", async () => {
let tx = await fd.openFD(ethers.utils.parseEther("1"), 2, {
value: ethers.utils.parseEther("1"),
});
await tx.wait();
assert.equal(
(await fd.getFD(accounts[0].address)).depositedAmount.toString(),
ethers.utils.parseEther("1").toString()
);
assert.equal(
(await fd.checkReturns(accounts[0].address)).toString(),
ethers.utils.parseEther("0.98").toString()
);
let fdDetail = await fd.getFD(accounts[0].address);
let tx1 = await fd.requestClaim();
await tx1.wait();
assert.equal((await fd.getFD(accounts[0].address)).status, 2);
await expect(fd1.approveClaim(accounts[0].address)).to.be.revertedWith(
"onlyOwner: Only owner can perform this operation"
);
await expect(fd.approveClaim(accounts[1].address)).to.be.revertedWith(
"canApproveClaim: Claim request does not exists"
);
let tx2 = await fd.approveClaim(accounts[0].address);
await tx2.wait();
assert.equal((await fd.getFD(accounts[0].address)).status, 3);
assert.equal(
(await fd.getFD(accounts[0].address)).claimAmount.toString(),
ethers.utils.parseEther("0.98").toString()
);
let tx3 = await fd.withdrawClaim();
await tx3.wait();
assert.equal((await fd.getFD(accounts[0].address)).status, 4);
assert.equal((await fd.getFD(accounts[0].address)).claimAmount, 0);
assert.equal(
(await bank.accountBalance(accounts[0].address)).toString(),
ethers.utils.parseEther("0.98").toString()
);
let tx4 = await fd.setPenaltyRate("3");
await tx4.wait();
let tx5 = await fd.setInterestRate("7");
await tx5.wait();
assert((await fd.getPenaltyRate()).toString(), "3");
assert((await fd.getInterestRate()).toString(), "7");
});
it("Check claim value after time exceeds", async () => {
let tx = await fd.openFD(ethers.utils.parseEther("1"), 2, {
value: ethers.utils.parseEther("1"),
});
await tx.wait();
await network.provider.send("evm_increaseTime", [60 * 3]);
await network.provider.request({ method: "evm_mine", params: [] });
assert.equal(
(await fd.checkReturns(accounts[0].address)).toString(),
ethers.utils.parseEther("1.1").toString()
);
});
});
});
Now we have built a contract we can develop a frontend for interacting with the contract for this you can follow the previous post where we built a frontend for ERC20 TOKEN and faucets Dapp.
How to build ERC 20 Token and Faucets Dapp
If you want a separate post for developing a front end, let me know in the comments. We will be back again with the new feature to our Bank smart contract. If you have any suggestions please let me know in the comments.
Thanks for reading this post, if you found this post helpful please share maximum. Will be back with amazing content on Web3. Stay tuned.
Find the code on Github
GitHub - CodeWithMarish/cwm-bank-dapp
Also please don’t forget to subscribe to our youtube channel codewithmarish for all web development-related challenges.