Mchango Series: Chapter 2

ChikezieChikezie
12 min read

Oh boy, let me tell you, many new blockchain developers fall into the trap of thinking that blockchain can completely replace traditional backend infrastructure. While this might hold for certain projects, it's generally not true—especially for applications that demand extensive data reading, writing, and rendering. In fact, for about 80% of scenarios, using blockchain as a substitute for a conventional database just doesn't cut it. And believe me, I learned this lesson the hard way.

When it came time to plan the architecture, I dove in by outlining the app's core functions. I pondered the implementation details and the appropriate tech stack to employ. The fundamental features of the app were straightforward: it enabled people to register, create groups, join groups, contribute, and disburse funds. It all seemed simple enough, right? Well, that was until my series of poor decisions led to a spectacular cascade of blunders.

Blunder 1: Create Group Function

Mike asked, "How are we going to store groups?" To which Chike confidently replied, "We'll just store the groups in the contract!" 😆

Now, don't get me wrong—storing data on the blockchain has its merits. It's secure, and there's no fretting over data loss if a server crashes. However, the decision to store data on the blockchain should not be taken lightly. The size of the data can have significant implications for both speed and cost, which may negate the benefits of security. This was a lesson we learned all too well.

To help you grasp the enormity of our oversight, let's delve into some blockchain basics.

On the blockchain, there are two fundamental types of operations: read and write. Reading from the blockchain doesn't cost a thing, though the speed can vary with network congestion. Writing, on the other hand, involves adding data to the blockchain, and this incurs a cost—known as gas fees in the native blockchain currency. In our case, we were working with the Ethereum network, so we dealt with Ether.

Here's a look at the data we ambitiously aimed to store on the blockchain:

 struct Group {
        uint256 id;
        uint256 collateral;
        uint256 contributionValue;
        address admin;
        string name;
        bytes32 description;
        uint256 balance;
        uint256 timer;
        uint256 timeLimit;
        address[] groupMembers;
        address[] eligibleMembers;
        mapping(address => Participant) participants;
        mapping(address => uint256) collateralTracking;
        State currentState;
    }

Here's the function to successfully store the data:

function createGroup(
        string memory _groupDescription,
        string memory _name,
        uint256 _contributionTimeLimit,
        uint256 _collateralValue
    ) external {
        require(isMember[msg.sender], "Only members can create groups");
        uint256 id = counter++;
        address admin = msg.sender;

        uint256 newContributionTimeLimit = _contributionTimeLimit + 0 seconds;
        uint256 definedCollateralValue = _collateralValue + 0 ether;

        Group storage newGroup = returnGroup(id);
        newGroup.id = id;
        newGroup.collateral = definedCollateralValue;
        newGroup.admin = admin;
        newGroup.name = _name;
        newGroup.description = Helper.stringToBytes32(_groupDescription);
        newGroup.balance = 0;
        newGroup.timeLimit = newContributionTimeLimit;
        newGroup.currentState = State.initialization;
        newGroup.groupMembers.push(admin);

        if (!isSubscriberPremium(admin)) {
            require(
                adminToGroup[admin].length < 1,
                "you can't create more than a group with a basic plan "
            );
            newGroup.groupMembers = new address[](10);
        }

        keys.push(id);
        adminToGroup[admin].push(id);
        admins.push(admin);

        emit hasCreatedGroup(admin, _groupDescription);
    }

For those who aren't well-versed in technical lingo, the details might come across as a bit complex. However, the key takeaway is that invoking the createGroup function on the blockchain came with a hefty price tag of $17.69, which translates to 22,121 naira. 😆 That's a glaring issue! For blockchain newcomers, the cost of gas—a fee paid for executing operations on the Ethereum network—often slips by unnoticed. This is because, during development, we typically work on a local blockchain or a testnet where there's an abundant supply of pretend Ether, and transactions are lightning-fast.

In our case, we were so focused on winning a hackathon that we overlooked the gas costs. Our priority was to ensure that the function worked as intended—and work it did. But the financial efficiency? Not so much.

Blunder 2: The Cost of Joining a Group

You might be thinking, "Come on, 3ill, it's just a function to join a group. There's no way it could be more expensive than creating one, right?" 😆 Well, hold onto your hats because even if it costs half as much, joining a group would still set you back a whopping 11,060 Naira—and that's just too steep.

Let's dissect thejoinGroup function to understand the process better. For a user to join a group, the following steps are necessary:

  • Verification as a registered user.

  • Confirmation that the group exists.

  • Payment of collateral.

  • Addition of the user's address to the group member array.

  • Registration of the user as a participant in the group.

Even from this brief rundown, you might sense an impending complication. If it's still not clear, let's dive into the code and see what's going on.

 function joinGroup(
        uint256 _id
    )
        external
        payable
        idCompliance(_id)
        groupExists(_id)
        collateralCompliance(msg.value, _id)
    {
        require(isMember[msg.sender], "Not a member");
        require(
            addressToMember[msg.sender].reputation != 0,
            "Not enough reputation point"
        );

        Group storage group = returnGroup(_id);
        address _memberAddress = msg.sender;

        require(msg.value >= group.collateral, "Not enough collateral value");
        require(
            group.currentState == State.initialization,
            "Cannot add members after contribution or collection has started"
        );

        //? Check if the group has reached its maximum number of members
        require(
            group.groupMembers.length < getMaxMembers(_memberAddress),
            "Maximum number of members reached"
        );
        require(
            group.collateralTracking[_memberAddress] == 0,
            "Already a member of this group"
        );

        makePayment(msg.value);
        group.collateralTracking[_memberAddress] = msg.value;

        //? Create a new participant
        Participant storage participant = group.participants[_memberAddress];
        participant.name = addressToMember[msg.sender].name;
        participant.participantAddress = _memberAddress;
        participant.hasDonated = false;
        participant.reputation = 1;

        //? Add the sender to the group's members list
        group.groupMembers.push(_memberAddress);

        emit joinedGroup(_memberAddress, group.name, block.timestamp);
    }

Joining a group, it turns out, comes with a not-so-modest fee of $104, or 130,000 naira. 😆 Yes, you read that right—it's significantly more expensive to join a group than to create one. Clearly, this is far from ideal; no application should require such a hefty sum just for basic usage.

The irony isn't lost on me—the exorbitant cost of trying to save money is downright laughable. 😆

But here's the thing: we were in the sandbox of a testnet, where we could freely tap into a virtual faucet for funds. No real money was at stake, so the pressure was off. Our focus was solely on functionality, ensuring the app was fully decentralized. The concept of gas efficiency? It barely crossed our minds. We were content as long as everything worked—until the real costs came into play.

[!NOTE] Ps. this is the second iteration of this function and not the final version

Blunder 3: Contribution and Rotation

Looking back with the knowledge I have now, I would have approached this differently. The contribution function is where the major blunder occurred, mainly because it's the heart of the DApp. As the most frequently called function, it's imperative for the contribution process to be both fast and gas-efficient. Logically, the cost of calling the contribution function should never exceed the amount being contributed. Yet, at this stage, I was still fervently surfing the decentralization wave.

Let's dissect the contribution function:

  • Confirm that the user is a group member.

  • Ensure the user hasn't already contributed for the current round.

  • Verify the group's state.

  • Update participant details.

  • Process the payment.

  • Move the participant to the end of the array.

It might not look like much, but keep in mind that executing this function involves two loops and several write operations. And I'm sure any smart contract developers reading this are cringing at the thought of the gas costs associated with loops, particularly on large arrays.

Take a gander at this behemoth of a code, and you'll see why this situation is almost comically disastrous.

function contribute(
        uint256 _id
    )
        external
        payable
        idCompliance(_id)
        groupExists(_id)
        contributionCompliance(_id, msg.value)
        notInitialization(_id)
        returns (string memory)
    {
        Group storage group = returnGroup(_id);
        Participant storage participant = group.participants[msg.sender];

        //? Check if the sender is an eligible member of the group
        require(
            group.collateralTracking[msg.sender] == group.collateral ||
                msg.sender == group.admin,
            "Not a valid member of this group"
        );
        require(
            !participant.hasDonated,
            "You have made your contributions for this round"
        );

        //? Check if the sender is eligible to contribute

        if (getGroupState(_id) == State.contribution) {
            bool isEligible = checkIsEligibleMember(group.groupMembers);
            require(isEligible == true, "Only group members can contribute");

            group.balance += msg.value;

            //? Update participant's contribution and eligibility
            participant.amountDonated += msg.value;
            participant.timeStamp = block.timestamp;
            participant.isEligible = true;
            participant.reputation = increaseReputation(msg.sender);
            participant.hasDonated = true;
            makePayment(msg.value);

            //? Add the sender to the eligible members list
            group.eligibleMembers.push(msg.sender);
        } else if (getGroupState(_id) == State.rotation) {
            bool isEligible = checkIsEligibleMember(group.eligibleMembers);
            require(
                isEligible == true,
                "Only eligible members can contribute in the rotation state"
            );

            group.balance += msg.value;

            //? Update participant's contribution and reputation
            participant.amountDonated += msg.value;
            participant.timeStamp = block.timestamp;
            participant.hasDonated = true;
            participant.reputation = increaseReputation(msg.sender);
            makePayment(msg.value);

            //? This ensures that contributers are arranged in order of their contribution
            uint indexToRemove = Helper.calculateIndexToRemove(
                msg.sender,
                group.eligibleMembers
            );
            Helper.shiftAndRemoveIndex(indexToRemove, group.eligibleMembers);
            group.eligibleMembers.push(msg.sender);
        }

        emit hasDonated(msg.sender, msg.value);

        //? Return a success message
        return "Contribution successful";
    }

You probably can't spot the loop because it has been abstracted into another file, so I'll paste the helper code below:

function calculateIndexToRemove(
        address _addressToRemove,
        address[] storage _array
    ) internal view returns (uint indexToRemove) {
        for (uint i = 0; i < _array.length; i++) {
            if (_array[i] == _addressToRemove) {
                indexToRemove = i;
            }
        }
    }

    function shiftAndRemoveIndex(uint _id, address[] storage _array) internal {
        for (uint i = _id; i < _array.length - 1; i++) {
            _array[i] = _array[i + 1];
        }

        _array.pop();
    }

Is that a storage parameter I'm seeing with my very own 👀? 😆

Oh, the peril of this function! As the list of group members swells, so does the cost of the function—because, as we all know, more iterations in the loop mean more gas. And who's left holding the bag for these soaring gas fees? The user, of course! 😆

Let's crunch some numbers. Imagine you're in a group where the contribution is a modest $50. Now brace yourself—it will cost you a staggering $125 just to make that $50 contribution. 😆😆 My chest!! 😆 The irony is as thick as the blockchain itself. You can see why this is a monumental blunder. But at the time, our eyes were fixed on one thing only: functionality. And function it did—albeit at a cost that would make even the most stoic of wallets weep.

Blunder 4: The Disbursement Debacle

Once the contribution period has run its course, the admin steps in to trigger the disbursement. This pivotal function transfers the pooled funds to an eligible group member, and then it's rinse and repeat—each member takes their turn until everyone has had their payout, signaling the end of the rotation.

This critical function was exclusively in the hands of group admins. To those intrepid souls who experimented with this iteration of the contract, I extend my deepest apologies for the oversight. It was never my intention to drain your Ether wallets 🙏. The blame lies with my inexperience and, let's say, a mischievous twist of fate.

Now, let's dissect the disburse function:

  • Confirm the group ID is valid.

  • Ensure the caller is indeed an admin.

  • Retrieve and store group data.

  • Penalize any members who fell short.

  • Identify the eligible member for disbursement.

  • Locate the participant's details.

  • Update the participant's status.

  • Execute the payment.

  • Relegate the member to the end of the queue.

  • Reset the contribution status for all members.

For a clearer picture, dive into the code and see just how this function plays out.

function disburse(
        uint256 _id
    ) external idCompliance(_id) onlyAdmin(_id) groupExists(_id) {
        Group storage group = idToGroup[_id];
        require(
            group.currentState == State.rotation,
            "Can only call disburse in rotation state"
        );

        //? penalize defaulters
        penalize(_id);

        //? get the eligible member
        address eligibleParticipant = getEligibleMember(_id);

        //? access the particpants array
        Participant memory participant = group.participants[
            eligibleParticipant
        ];
        require(participant.hasDonated, "this participant hasn't contributed");
        //? update the amountCollectes state
        uint256 amount = getNewBalance(_id);
        participant.amountCollected = amount;

        //? update the timestamp
        participant.timeStamp = block.timestamp;

        //? update the has receivedFunds state
        participant.hasReceivedFunds = true;
        makePayment(amount);

        //? push eligible member to the back of the array
        uint indexToRemove = Helper.calculateIndexToRemove(
            participant.participantAddress,
            group.eligibleMembers
        );

        //? check if address is already in last position
        if (indexToRemove != group.eligibleMembers.length - 1) {
            Helper.shiftAndRemoveIndex(indexToRemove, group.eligibleMembers);
        }
        group.eligibleMembers.push(participant.participantAddress);

        resetMembersDonationState(_id);
        group.timer = block.timestamp;

        emit hasReceivedFunds(participant.participantAddress, amount);
    }

Bunch of loops 😢

  function penalize(uint256 _id) internal {
        Group storage group = idToGroup[_id];
        require(
            group.currentState == State.rotation,
            "Group not in rotation state yet"
        );
        require(block.timestamp > (group.timer + group.timeLimit));

        address[] memory defaults = getDefaulters(_id);


        collateralAndDisciplineTrigger(_id, defaults);
    }

   function getDefaulters(
        uint256 _id
    ) internal view groupExists(_id) returns (address[] memory defaulters) {
        Group storage group = returnGroup(_id);

        require(
            group.currentState == State.rotation,
            "Function can only be called in rotation state"
        );

        uint256 defaulterCount = 0;

        //? Count the number of defaulters
        for (uint256 i = 0; i < group.groupMembers.length; i++) {
            if (!group.participants[group.groupMembers[i]].hasDonated) {
                defaulterCount++;
            }
        }

        //? Initialize the array with the correct length
        defaulters = new address[](defaulterCount);
        uint256 index = 0;

        //? Populate the array with defaulter addresses
        for (uint256 i = 0; i < group.groupMembers.length; i++) {
            address member = group.groupMembers[i];
            if (!group.participants[member].hasDonated) {
                defaulters[index] = member;
                index++;
            }
        }

        return defaulters;
    }

function collateralAndDisciplineTrigger(
        uint256 _id,
        address[] memory _defaulters
    ) internal {
        Group storage group = returnGroup(_id);

        for (uint256 i = 0; i < _defaulters.length; i++) {
            uint256 collateralAmount = group.collateralTracking[_defaulters[i]];

            if (collateralAmount >= group.contributionValue) {
                address defaulterToMove = _defaulters[i];
                handleExcessCollateral(defaulterToMove, _id);
            } else if (collateralAmount < group.contributionValue) {
                address defaulterToMove = _defaulters[i];
                handleLessCollateral(defaulterToMove, _id);
            }
        }
    }

  function handleExcessCollateral(address _defaulter, uint256 _id) internal {
        Group storage group = returnGroup(_id);

        //? Subtract contributionValue from collateralAmount
        group.collateralTracking[_defaulter] -= group.contributionValue;
        addressToMember[_defaulter].reputation -= 1;

        //? Find the index of defaulterToMove in eligibleMembers array
        uint256 indexToRemove = Helper.calculateIndexToRemove(
            _defaulter,
            group.eligibleMembers
        );

        //? Remove the defaulter from eligibleMembers array
        Helper.shiftAndRemoveIndex(indexToRemove, group.eligibleMembers);
    }

  function handleLessCollateral(address _defaulter, uint256 _id) internal {
        Group storage group = returnGroup(_id);

        group.contributionValue - group.collateralTracking[_defaulter];
        addressToMember[_defaulter].reputation -= 2;

        //? Find index of defaulter in eligibleMembers array
        uint256 indexToRemove = Helper.calculateIndexToRemove(
            _defaulter,
            group.eligibleMembers
        );

        //? Remove the defaulter from eligibleMembers array
        Helper.shiftAndRemoveIndex(indexToRemove, group.eligibleMembers);

        //? remove the member from the groupMembers array
        Helper.removeAddress(_defaulter, group.groupMembers);

        emit memberKicked(group.participants[_defaulter].name, _defaulter);
    }

Wow, talk about a costly operation! With a series of loops and storage operations, it's no surprise that the cost of executing this was astronomical. And let's put this into perspective for the Nigerian market, which was the primary target for this DApp. According to our survey, most participants interested in group contributions were in it for financial benefits. It's utterly unrealistic—and frankly, a bit cruel—to expect them to fork over $200 just so someone can receive a $500 group balance. The numbers just don't add up.

The functions I've highlighted here barely scratch the surface of Mchango's codebase, but they should give you a sense of how the contract functioned and the magnitude of our missteps.

I'm not trying to make excuses for myself or the team. Keep in mind that we had a mere two weeks to develop this DApp, with no backend engineer on board and the relentless pressure from the Aya team to deliver an MVP. So, our singular focus was on functionality; considerations of gas efficiency and scalability were, quite frankly, an afterthought.

I hope you've found this account enlightening—or at least entertaining. This is just a glimpse into the chaos of development. Stay tuned for more tales of outrageous engineering escapades.

Up Next: Frontend Architecture?

0
Subscribe to my newsletter

Read articles from Chikezie directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Chikezie
Chikezie

I'm an unstoppable force in the world of blockchain development, a Web3 wizard, and a data maestro! I'm all about dishing out mind-blowing tech content, so make sure you're subscribed to stay ahead of the curve on the hottest web3 trends.