Decentralized Tipping for Service Providers on the Celo Blockchain

Introduction

Welcome to another tutorial by yours truly :blush:. If you are here for the first time, I greet you, please give me a like if you find this helpful. If this is not your first article, drop me a like and you can also follow me for more tutorials.

In this tutorial, you will gain a solid understanding of building a decentralized tipping system on the celo blockchain. You would learn how to allow users to send tips to registered staff members by specifying the recipient’s name and sending a desired amount.

Come along

Prerequisites

  • Prior knowledge of javascript
  • Familiarity with the command line
  • Basic understanding of blockchain concepts
  • Have some knowledge of solidity and its concepts
  • Remix

Requirements

  • A code editor or text editor. VSCode is recommended
  • A terminal. Git Bash is recommended
  • An Internet Browser and a good internet connection

Getting Started

Remix is a widely used integrated development environment (IDE) available on the web. If you have been following my previous tutorials, you are likely familiar with it. Remix offers a user-friendly interface that enables developers to write, test, and deploy smart contracts, with a particular focus on the Celo blockchain. It provides a convenient platform to facilitate the development process for Celo-based projects.

To get started with Remix, you would create a new workspace and proceed to create a new file, which can be named as TipJar.sol for our example.

To ensure clarity and compliance, it is customary to include a statement at the beginning of your TiJar.sol file that explicitly states the license under which your code is being released. This practice helps in establishing the licensing terms for your code and ensures that users understand how they can utilize and distribute it.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

Next, we define a struct called Wallet within the TipJar contract. This struct will be used within the contract to store and retrieve information about employees, such as their names and wallet addresses. It provides a convenient way to organize and manage employee data within the TipJar contract.

contract TipJar {
    struct Wallet {
        string name;
        address walletAddress;
    }

The Wallet struct represents the wallet information associated with each employee. It has two properties:

  • name: A string that stores the name of the employee.
  • walletAddress: An Ethereum address that represents the employee’s wallet address.

By using a struct, we can conveniently group these related properties together and access them as a single unit. It allows us to define the structure of the employee’s wallet information and reuse it whenever needed.

In the context of the TipJar contract, we define the Tip struct which represents a tip given from one address to another. By using the Tip struct, the contract can conveniently store and access information related to each tip. It allows for efficient organization and retrieval of tip data within the TipJar contract. For example, when a tip is given, an instance of the Tip struct is created and populated with the relevant information such as the message, amount, sender, receiver, and timestamp.

    struct Tip {
        string message;
        uint256 amount;
        address sender;
        address receiver;
        uint256 timeStamp;
    }

The Tip struct contains the following properties:

  • message: A string that stores an optional message or comment accompanying the tip.
  • amount: A uint256 variable that represents the value of the tip, specified in the smallest unit of the network’s native currency (e.g., wei in Ethereum).
  • sender: An Ethereum address that identifies the account from which the tip originates.
  • receiver: An Ethereum address that identifies the account that receives the tip.
  • timeStamp: A uint256 variable that stores the timestamp when the tip was created, represented as the number of seconds since the Unix epoch.

Up next, several mappings and variables are defined to manage and track different aspects of the tipping system. By using these mappings and variables, the contract can efficiently manage and retrieve information related to employees, tips, and their associations.

    mapping(string => address) nameToAddressMapping;
    mapping(uint256 => Tip) tips;
    mapping (address => Wallet) wallets;
    mapping(address => uint256[]) ownTips;
    string[] namesTaken;

    uint256 tipsTracker = 0;    
  1. mapping(string => address) nameToAddressMapping: This mapping associates an employee’s name (string) with their wallet address (address). It allows for efficient lookup of an employee’s wallet address based on their name.

  2. mapping(uint256 => Tip) tips: This mapping is used to store and retrieve tip information. The key is a unique identifier (uint256) for each tip, and the value is an instance of the Tip struct, containing details such as the tip message, amount, sender, receiver, and timestamp.

  3. mapping(address => Wallet) wallets: This mapping connects an employee’s wallet address (address) with their associated Wallet struct. It allows for easy access to wallet information based on the wallet address.

  4. mapping(address => uint256[]) ownTips: This mapping links an employee’s wallet address with an array of tip identifiers (uint256) representing the tips they have received. It enables efficient retrieval of all the tips associated with a specific employee.

  5. string[] namesTaken: This array stores the names that have already been assigned to employees. It is used to check if a name is already taken when creating a new employee.

  6. uint256 tipsTracker = 0: This variable keeps track of the total number of tips given. It is incremented every time a new tip is created, ensuring each tip has a unique identifier.

Up next, we define the isTaken function, The isTaken function is a utility function used within the TipJar contract to check whether a given name _name is already taken by another employee.

function isTaken(string calldata _name, string[] memory _namesTaken) private pure returns (bool) {
  • The function isTaken takes two parameters: _name, which is the name to be checked, and _namesTaken, an array of names that have already been assigned to employees.
  • The function is marked as private, which means it can only be called from within the contract.
  • The pure keyword indicates that the function does not modify the contract’s state.
for (uint256 i = 0; i < _namesTaken.length; i++) {
    if (keccak256(abi.encodePacked(_namesTaken[i])) == keccak256(abi.encodePacked(_name))) {                
        return true;
    }
}
  • The function uses a for loop to iterate through each element in the _namesTaken array.
  • Within the loop, the function compares each element of _namesTaken with the provided _name. This comparison is performed by hashing both names using the keccak256 function and comparing the resulting hash values.
  • If a match is found (i.e., the names are identical), the function immediately returns true, indicating that the name is already taken.
return false;
  • If the loop completes without finding a match, meaning that the provided _name is not found in the _namesTaken array, the function returns false, indicating that the name is available.

Following, we define the createNewWallet function. The createNewWallet function in the TipJar contract is used to create a new wallet for an employee by associating their provided name and wallet address.

function createNewWallet(string calldata _name, address _walletAddress) public {
  • The function createNewWallet takes two parameters: _name, which is the name of the employee, and _walletAddress, which is the wallet address associated with the employee.
  • The function is marked as public, allowing external entities to call it.
bool taken = isTaken(_name, namesTaken);
require(taken == false, "Name already taken");
  • The first line of the function calls the isTaken function to check if the provided _name is already taken by another employee. The result is stored in the taken variable.
  • The next line uses a require statement to ensure that the taken variable is false. If the name is already taken (i.e., taken is true), the function execution is halted, and an error message “Name already taken” is reverted.
namesTaken.push(_name);
wallets[_walletAddress] = Wallet(_name, _walletAddress);
nameToAddressMapping[_name] = _walletAddress;
  • If the provided name is not already taken, the function proceeds to store the new wallet information.
  • The line namesTaken.push(_name) adds the _name to the namesTaken array, keeping track of the assigned names.
  • wallets[_walletAddress] = Wallet(_name, _walletAddress) assigns a new Wallet struct to the wallets mapping. The struct is initialized with the provided _name and _walletAddress.
  • Finally, nameToAddressMapping[_name] = _walletAddress establishes a mapping between the _name and _walletAddress, allowing for easy lookup of the wallet address based on the name.

Next up, we define the tip function. the tip function allows users to send a tip to a staff member. It verifies the name and address of the staff member, creates a new tip object, associates the tip with the staff member, transfers the tip amount, and ensures the transfer is successful.

function tip(
    string calldata _message,
    string calldata _name,
    address _to
) public payable {
  • The function tip takes three parameters: _message (the message accompanying the tip), _name (the name of the staff member), and _to (the address of the staff member).
  • The function is marked as public, meaning it can be called externally by users.
  • It is also marked as payable because it allows users to send Ether along with the tip.
require(
    nameToAddressMapping[_name] == _to,
    "Name or Address not registered yet!"
);
  • The require statement ensures that the provided _name corresponds to the given _to address. It checks if the nameToAddressMapping contains a valid mapping for the _name provided. If not, it reverts the transaction with the error message “Name or Address not registered yet!”.
uint256 _amount = msg.value;
  • The _amount variable is assigned the value of the Ether sent with the transaction (msg.value). This represents the amount of the tip being sent by the user.
tips[tipsTracker] = Tip(_message, _amount, msg.sender, _to, block.timestamp);
  • A new tip object is created using the Tip struct and assigned to the tips mapping at the tipsTracker index. The tip object contains the provided _message, _amount, the sender’s address (msg.sender), the receiver’s address (_to), and the current timestamp (block.timestamp).
ownTips[_to].push(tipsTracker);
  • The tip identifier (tipsTracker) is added to the array of tips (ownTips) associated with the staff member’s address (_to). This allows the staff member to easily retrieve their own tips.
tipsTracker += 1;
  • The tipsTracker variable is incremented to ensure the next tip gets a unique identifier.
(bool sent, ) = payable(_to).call{value: _amount}("");
require(sent);
  • The tip amount (_amount) is transferred to the staff member’s address (_to) using the payable function modifier. The transfer is executed by calling the call function with the value parameter set to the tip amount. If the transfer fails, the transaction reverts and fails, ensuring that the tip is only sent if the transfer is successful.

Up next, we define the readTip function. The readTip function in the TipJar contract allows the recipient of a tip to read the details of a specific tip by providing the tipId.

function readTip(uint256 tipId)
    public
    view
    returns (
        string memory message,
        uint256 amount,
        address sender,
        uint256 timeStamp
    )
{
    Tip memory _tip = tips[tipId];
    require(msg.sender == _tip.receiver, "Not allowed to view tip");
    message = _tip.message;
    amount = _tip.amount;
    sender = _tip.sender;
    timeStamp = _tip.timeStamp;
}
  • The readTip function takes one parameter: tipId, which represents the identifier of the tip to be read.
  • The function is marked as public and view since it only retrieves information from the contract without modifying the state.
  • The return values are specified as a tuple, including message (the message accompanying the tip), amount (the tip amount), sender (the address of the tip sender), and timeStamp (the timestamp when the tip was created).
Tip memory _tip = tips[tipId];
  • The function retrieves the tip object associated with the provided tipId from the tips mapping and assigns it to a local variable _tip of the Tip struct type.
require(msg.sender == _tip.receiver, "Not allowed to view tip");
  • The require statement verifies that the caller of the function (msg.sender) is the intended recipient of the tip (_tip.receiver). If the condition is not met, the function execution is halted, and an error message “Not allowed to view tip” is reverted.
message = _tip.message;
amount = _tip.amount;
sender = _tip.sender;
timeStamp = _tip.timeStamp;
  • If the caller is allowed to view the tip, the function proceeds to assign the corresponding values from the _tip object to the respective return variables.
  • The message, amount, sender, and timeStamp variables are set based on the values stored in the retrieved _tip object.

Next up, we define the readMyTips and readNamesTaken functions which provide convenient ways to retrieve information about tips and registered names.

Let’s break down each function:

function readMyTips() public view returns (uint256[] memory) {
    return ownTips[msg.sender];
}
  • The readMyTips function is marked as public and view since it only reads information from the contract without modifying the state.
  • The function returns an array of uint256 values, representing the tip IDs associated with the caller’s (sender’s) address.
  • It retrieves the tip IDs stored in the ownTips mapping, using msg.sender as the key (the caller’s address), and returns the array of tip IDs.
function readNamesTaken() public view returns (string[] memory) {
    return namesTaken;
}
  • The readNamesTaken function is also marked as public and view.
  • It returns an array of string values, representing the list of names that have already been taken.
  • The function simply returns the namesTaken array, which holds the registered names.

Then, we define the isStaff and checkAddressWithName functions which provide ways to check if a user is a staff member and retrieve the address associated with a given name, respectively.

function isStaff(string calldata _name) public view returns (bool) {
    return (nameToAddressMapping[_name] != address(0));
}
  • The isStaff function takes a string parameter _name, representing the name to check.
  • It is marked as public and view, indicating that it doesn’t modify the contract’s state.
  • The function checks if the address associated with the provided _name in the nameToAddressMapping mapping is not equal to the zero address (address(0)).
  • If the condition is true, it means the name is associated with a valid address, indicating that the user is a staff member. The function returns true, otherwise, it returns false.
function checkAddressWithName(string calldata _name) public view returns (address) {
    return nameToAddressMapping[_name];
}
  • The checkAddressWithName function takes a string parameter _name, representing the name for which to retrieve the associated address.
  • It is marked as public and view.
  • The function retrieves the address associated with the provided _name from the nameToAddressMapping mapping and returns it.
  • If the _name is not found in the mapping, the function will return the zero address (address(0)).

Next, we define the The checkNameWithAddress function which allows you to retrieve the name associated with a given address. This function allows you to fetch the associated name for a given address by looking up the corresponding Wallet object in the wallets mapping. It retrieves the name property from the retrieved Wallet object and returns it as a result. It can be useful when you want to retrieve the name associated with an address in the context of the TipJar contract.

function checkNameWithAddress(address _address) public view returns (string memory) {
    Wallet memory wallet = wallets[_address];        
    string memory name = wallet.name;
    return name;
}
  • The checkNameWithAddress function takes an address parameter _address, representing the address for which to retrieve the associated name.
  • It is marked as public and view, indicating that it doesn’t modify the contract’s state.
  • Inside the function, a local variable wallet of the Wallet struct type is declared and assigned the value retrieved from the wallets mapping using the _address as the key.
  • The wallet.name property is then assigned to another local variable name of type string.
  • Finally, the function returns the name.

Here is the full code:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

contract TipJar {
    struct Wallet {
        string name;
        address walletAddress;
    }

    struct Tip {
        string message;
        uint256 amount;
        address sender;
        address receiver;
        uint256 timeStamp;
    }

    mapping(string => address) nameToAddressMapping;
    mapping(uint256 => Tip) tips;
    mapping (address => Wallet) wallets;
    mapping(address => uint256[]) ownTips;
    string[] namesTaken;

    uint256 tipsTracker = 0;    

    // return true if "_name" is taken, false otherwise
    function isTaken(string calldata _name, string[] memory _namesTaken) private pure returns (bool) {
            
        for (uint256 i = 0; i < _namesTaken.length; i++) {
            if (keccak256(abi.encodePacked(_namesTaken[i])) == keccak256(abi.encodePacked(_name))) {                
                return true;
            }
        }
        return false;
    }

    // Staff create new wallet here
    function createNewWallet(string calldata _name, address _walletAddress)
        public
    {        
        bool taken = isTaken(_name, namesTaken);
        require(taken == false, "Name already taken");
        namesTaken.push(_name);
        wallets[_walletAddress] = Wallet(_name, _walletAddress);
        nameToAddressMapping[_name] = _walletAddress;
    }

    // User tip a staff
    function tip(
        string calldata _message,
        string calldata _name,
        address _to        
    ) public payable {
        require(
            nameToAddressMapping[_name] == _to,
            "Name or Address not registered yet!"
        );
        uint256 _amount = msg.value;
        // create a  new tip object
        tips[tipsTracker] = Tip(_message, _amount, msg.sender, _to, block.timestamp);
        // assign tips object to staff
        ownTips[_to].push(tipsTracker);
        tipsTracker += 1;
        // transfer tip amount to staff
        (bool sent, ) = payable(_to).call{value: _amount}("");
        require(sent);
    }

    // read a tip details from storage
    function readTip(uint256 tipId)
        public
        view
        returns (
            string memory message,
            uint256 amount,
            address sender,            
            uint256 timeStamp
        )
    {
        Tip memory _tip = tips[tipId];
        require(msg.sender == _tip.receiver, "Not allowed to view tip");
        message = _tip.message;
        amount = _tip.amount;
        sender = _tip.sender;        
        timeStamp = _tip.timeStamp;
    }

    // return an array of IDs of user's tips
    function readMyTips() public view returns (uint256[] memory) {
        return ownTips[msg.sender];
    }

    // return list of names taken
    function readNamesTaken() public view returns(string[] memory) {
        return namesTaken;
    }
    // check if user is a staff member
    function isStaff(string calldata _name) public view returns (bool) {
        return (nameToAddressMapping[_name] != address(0));
    }

    // return address of an associated name
    function checkAddressWithName(string calldata _name)
        public
        view
        returns (address)
    {
        return nameToAddressMapping[_name];
    }

    // get wallet details 
    function checkNameWithAddress(address _address) public view returns (string memory) {
        Wallet memory wallet = wallets[_address];        
        string memory name = wallet.name;
        return name;
    }
}

Contract Deployment

To deploy the contract, we would need:

  1. CeloExtensionWallet
  2. Celo Faucet
  3. Celo Remix Plugin

Download the Celo Extension Wallet from the Google chrome store using the link above. After doing that, create a wallet, and store your key phrase in a very safe place to avoid permanently losing your funds.

After downloading and creating your wallet, you will need to fund it using the Celo Faucet. Copy the address to your wallet, click the link to the faucet above and paste the address into the text field, and confirm.

Next up, on remix, download and activate the celo plugin from the plugin manager. Connect your wallet and deploy your contract.

Conclusion

In this tutorial, We covered various aspects of the contract, including the use of structs to organize data, mappings to efficiently store and retrieve information, and functions to create new wallets, send tips, and retrieve tip details.

Throughout the tutorial, you learned how to implement features such as name validation, associating names with addresses, and checking if a user is a staff member. Additionally, you gained insights into managing data arrays, enforcing access restrictions, and performing value transfers within the contract.

Next Steps

By following this tutorial, you should now have a solid understanding of developing similar contracts and be equipped with the knowledge to handle data storage, user interactions, and tip transactions in your own projects.
You could try implementing a frontend or try adding other functions based on what you have learnt.

Here is the repository for the codebase here

About The Author

Daniel Ogbuti is a web3 developer with a passion for teaching as well as learning. I would love to connect on Twitter @daniel_ogbuti and linkedin: Daniel Ogbuti

See you soon!

8 Likes

this is actually an interesting topic , looking forward to it .

3 Likes

Wow, I will like to see how this will work out

4 Likes

This is unique one here. I will like to see how it comes out

4 Likes

very creative

2 Likes

Fantastic news! Your proposal has landed in this week’s top voted list. As you begin your project journey, remember to align with our community and technical guidelines, ensuring a high quality platform for our developers. Congratulations! :mortar_board: :seedling:

Note: @danielogbuti based on the number of votes you received, we would like to proceed with your tutorial. However, it lacks specific technical details, making it unclear what you plan to build and demonstrate in the article. Kindly revise the article to align with our proposal guidelines. Once you are ready, we will conduct an additional review and proceed with rewarding you for this tutorial. Thank you!

2 Likes

@Celo_Academy please checkout the changes

2 Likes

looking forward

2 Likes

Thanks for the revision. Approved and ready for you to get started whenever you’d like :mortar_board: :seedling:

Hi @danielogbuti i will be reviewing this

2 Likes