Your smart contract is ready to be used, since you have confidence in the code after testing it.
But there are a few things that you can add to improve the behavior and security of it.
Let's start by adding events.
Similar to any other programming language Events in solidity are a way to inform the calling application about the current state of the contract. Like notifying the javascript client about a change made to the contract allowing the client application to react to it.
Events are defined within the contract as a global and are called from inside a function. To declare an event you use the event
keyword, followed by an identifier and the parameter list. The values passed to the parameters are then used to log the information that is saved as part of the transaction inside the block.
To send the event you'll use the emit
keyword from anywhere inside the contract by passing the name of the event and the corresponding parameters.
For this contract you want to let the client application to know when a tip was successfully sent. To do that you'll create a new event named NewTip
like this
event NewTip(address indexed from, string message, string name, uint256 amount);
It will accept an address to identify who sent the tip, the message of the tip and also the name and the amount of eth sent,
And then, it will be emitted from the sendTip
function
// send the event
emit NewTip(msg.sender, _message, _name, msg.value);
This event will use the same parameters that the rest of the sendTip
operation.
msg.sender
to get the address of who sent the tip_message
the string with the message sent_name
the name of the sendermsg.value
the amount ofeth
sent.
Sice you modify your contract, you need to also update your tests, in this case is about checking that the event is actually send. To accomplish that, let's create a new test case under test/TipJar.js
with the following content
it('should react to the tip event', async function () {
const [, sender] = await ethers.getSigners(); // Get the address of the sendder
const amount = ethers.utils.parseEther('0.1');
const tipContract = contract.connect(sender);
const tx = await tipContract.sendTip('event message', 'name', { value: amount }); // Perform the transaction
await tx.wait();
expect(tx).to.emit(contract, 'NewTip').withArgs(sender.address, 'event message', 'name', amount); // Check that the event was sent
});
The last line of the scenario is the important one here, it allow you to check if an event was triggered or not.
The contract is ready to be ship, but you can do a little bettter. An smart contract is identify by an address, same as you, the owner of the contract. This address is the identifier of an account and an account can store a balance, meaning that the smart contract can also store a balance of some eth. Why is this important? Because you can tweak the contract a bit to instead of using it as a bridge to allow someone to send some eth directly to you it will store the tip in the contract itself and then allow you to withdraw that amount whenever you want.
Why you'll want to do something like this? To lower the gas costs. By doing this the total gast cost of the operation will be lesser for the sender since the you (the owner of the contract) will share some of the gas for retrieving the funds from the contract. So, is a way to care about the people that want to support you through your tip jar.
Ok, you're convicend? Yes?. Cool, now you're wondering. How can I and only I withdraw the funds ?
To do that, you'll need to create a new function in the contract that send the funds from the contract to your address but that ensures that only you can do it.
This can be done by checking the address of the function caller msg.sender
against the owner
variable that holds the owner address. Something like
function withdraw() public{
require(owner == msg.sender, 'caller is not the owner');
}
That require
statement will ensure that only the owner can execute whatever code is under it.
Good, you just added some security measure to the widthraw function.
This is a good time to introduce a new Solidity concept: Modifiers
A function modifier is a way to change the behavior of a function without actually updating the body of the function code (like the decorator pattern). The modifier is attached to the function definition to change the function's behavior.
A modifier looks just like a function buyt use the keyword modifier
instead and it can't be directly called.
Why you want modifiers? Is a simple way to create reusable pieces of code that can be shared across your contract. In this case you have only one function that requires ownership but, if in the future you want to add a few more functions you can benefit by having the ownership requirement as a shareable code.
This is known as "Access control" - that is "who is allowed to do this thing" - is really important in the world of smart contracts, it may govern who can mint tokens, vote, withdraw, transfer, etc. OpenZeppellin contracts ofer utilities to handle access control. What you'll see in this course is the very basic form of access control: ownership.
Let's create a modifir based on the prior require
statement
modifier onlyOwner() {
require(owner === msg.sender, "caller is not the owner");
_;
}
function withdraw() public onlyOwner {
}
This modifier is really simple, only one require
statement and no arguments. To create a modifier you need to use the modifier
keyword and end the block with the _;
symbol.
This symbol is called a merge wildcard. It merges the function code with the modifier code where the _;
is placed (A modifier is a compile-time source code roll-up).
In other words, the body of the function will be inserted in the place where the symbol appears.
The symbol is mandatory for the modifier but can be placed anywhere inside the modifier body, depending on when the function body has to be exeecuted.
Now, you need to modify the current sendTip
function to instead of immediatly send the eth
to your account, store it in the contract.
Let`s change the function like this
//Update the sendTip function
function sendTip(string memory _message, string memory _name) public payable {
require(msg.sender.balance >= msg.value, "Youd don't have enough funds"); // require that the sender has enough ether to send
// THIS IS THE CHANGE
// The `call` function was removed
// END OF THE CHANGE
totalTips += 1; //increase the amount of tips
tips.push(Tip(msg.sender, _message, _name, block.timestamp, msg.value)); // Store the tip
// send the event
emit NewTip(msg.sender, _message, _name, msg.value);
}
The main change is that the usage of call
method was removed, so: How the eth is stored in the contract? This is a solitidy feature. Since sending eth is the basic use case of a contract, if a function is payable and msg.value
have some value, then that value is stored in the contract immediately :D.
Now, the contract will act as centralized bank that store all the money sent to you, but you don't actually have it in your account. You need a way to extract that money, that is the withdraw
function that you drafted above.
What will be the body of that function?
// lets add a new event
event NewWithdrawl(uint256 amount);
/*
* Allow the owner to withdraw all the ether in the contract
*/
function withdraw() public onlyOwner {
uint256 amount = address(this).balance; // get the amount of ether in the contract
require(amount > 0, 'You have no ether to withdraw');
(bool success, ) = owner.call{value: amount}('');
require(success, 'Withdraw failed');
emit NewWithdrawl(amount);
}
Is actually simple. It first get the balance of the contract, then will check if there is something to withdraw and if so will perform the transfer call to your address for the whole amount of the contract balance. Then will check if the transaction was successful and will emit an event to notify of it.
Since the contract change again, you need to update the tests. The main change will be in the second tests, the one that check the balances. Now instead of checking the owner balance, should check the contract balance.
it('Should allow to send a tip and increase the number of total tips', async function () {
const [owner, sender] = await ethers.getSigners(); // Get two addresses, the owner and the sender
const balance = await ethers.provider.getBalance(contract.address); // Get the account balance of the contract
const senderBalance = await sender.getBalance();
/*
* perform the send transaction
* You pass the message and the name as arguments and the value as an object that is then
* used as the global `msg` object in the contract
* to define the amount of ETH use the parseEther utility
*/
const tx = await contract
.connect(sender)
.sendTip('message', 'name', { value: ethers.utils.parseEther('0.001') });
await tx.wait();
const newBalance = await ethers.provider.getBalance(contract.address); // Get the new balance of the contract
const newSenderBalance = await sender.getBalance();
expect(newBalance).to.be.above(balance); // Check that the new balance if greater than before
expect(newSenderBalance).to.be.below(senderBalance); // Check that the sender balance is less than before
expect(await contract.getTotalTips()).to.equal(1); // Get the total number of tips
});
Will also add two more tests, one to check if the owner can withdraw the balance and other to check that no one else than the owner can withdraw
it('should allow the owner to withdraw the whole balance', async function () {
const [owner] = await ethers.getSigners();
const ownerBalance = await owner.getBalance(); // get the owner balance
const originalContractBalance = await ethers.provider.getBalance(contract.address); // Get the contract balance
const tips = await contract.getAllTips(); //Get all the tips
const sumTips = tips.reduce((acc, tip) => acc.add(tip.amount), ethers.BigNumber.from(0)); // Get the total amount of tips
expect(sumTips).to.be.equals(originalContractBalance); //Since this is the first widthdraw the sum of the tips should be the same as the contract balance
/*
* perform the withdraw transaction
*/
const tx = await contract.withdraw();
await tx.wait();
const newOwnerBalance = await owner.getBalance(); // Get the new balance of the owner
const contractBalance = await ethers.provider.getBalance(contract.address); // Get the account balance of the contract
expect(newOwnerBalance).to.be.above(ownerBalance); // Check that the new balance if greater than before
expect(contractBalance).to.be.equal(0); // Check that the contract balance is zero
// Since there is no balance in the contract
// the withdraw should fail
await expect(contract.withdraw()).to.be.reverted;
});
it('should reject withdrawal from another address different than owner', async function () {
const [, otherUser] = await ethers.getSigners();
// Try to withdraw again
// it should fail since there is no eth in the contract
await expect(contract.connect(otherUser).withdraw()).to.be.reverted;
});
Run the test in the terminal
$ npm run hardhat:test
And check the results