Writing scripts to simulate Smart Contract interaction using Truffle Suite

Before diving deep into the framework a short description about the game we're building. It's a Rock Paper Scissors game where users can play against the house with a 50/50 win/lose rate. If the player wins the game, we're doubling your wager. When the player loses, the wager will stay in the contract and when it's a tie the contract refunds your wager.

We have two Smart Contracts: One NFT contract where users can mint 550 different NFTs and a Game Treasury contract. While playing the game our contract takes a fee if a player wins or loses the game. This fee will be distributed over the NFT holders.

So what exactly are we trying to accomplish? We've built the game, our Smart Contracts are done, our Unit Tests are all passing. What else can we do? We want to make sure that everything works as expected, so how can we be sure everything works? We'll need to test our application in every way possible, but these things cost a lot of time. We want to know if our NFT fee distribution works as expected, but before we can test our distribution method, we first have to mint all the NFTs with different wallets, play more than 100 games to collect the fee that will be distributed. After all this is done we can finally test the distribution method. As a developer I can be really lazy, so why not automate all of this?

Our scripts do the following:

  • We have a script that will mint 550 NFTs with 9 different wallets
  • We have a script that will play the game 140 times with 6 different wallets
  • We have a script that will distribute the fee to the 9 holders

Let's dive into Truffle Suite!

"A world class development environment, testing framework and asset pipeline for blockchains using the Ethereum Virtual Machine (EVM), aiming to make life as a developer easier."

Sounds really promising and making a developer's life easier sounds pretty good, right?

Our first step is to write something that will mint all our NFTs using 9 different wallets. Truffle offers us something called scripts. These external scripts can interact with your Smart Contract and looks like this:

module.exports = function(callback) {
  // TODO: implement your actions

  // invoke callback
  callback();
}

You can do everything in this function as long as the callback is invoked when the script finishes.

Let's see how our script looks like:

const nftContract = artifacts.require("NFT");

module.exports = function(callback) {
  (async () => {
    let counter = 0;
    const contractInstance = await nftContract.deployed();
    const accounts = await web3.eth.getAccounts()

    let minter = accounts[1]; // 0-49 50 NFTs
    while (counter <= 549) {
      if (counter > 49 && counter <= 150) {
        minter = accounts[2]; // 50-150 100 NFTs
      } else if (counter > 150 && counter <= 200) {
        minter = accounts[3]; // 151-200 50 NFTs
      } else if (counter > 200 && counter <= 225) {
        minter = accounts[4]; // 201-225 25 NFTs
      } else if (counter > 225 && counter <= 300) {
        minter = accounts[5]; // 226-300 75 NFTs
      } else if (counter > 300 && counter <= 360) {
        minter = accounts[6]; // 301-360 60 NFTs
      } else if (counter > 360 && counter <= 445) {
        minter = accounts[7]; // 361-445 85 NFTs
      } else if (counter > 445 && counter <= 500) {
        minter = accounts[8]; //446-500 55 NFTs
      } else if (counter > 500) {
        minter = accounts[9]; // 501-549 49 NFTs
      }
      // Mint NFT
      try {
        await contractInstance.Mint(1, {from: minter, value: web3.utils.toWei("0.1")});
        const totalSupply = await contractInstance.totalSupply.call();
        console.log(`NFT #${totalSupply.toString()} minted.`);
      } catch (error) {
        console.log(error);
      }
      console.log('\n');
      counter++;
    }
    console.log(`Total NFTs minted: ${counter}`);

    callback();
  })();
}
Script to mint all NFTs with six different wallets

Our script has a while loop and will invoke the callback when all NFT's are minted. We've commented how many NFTs each wallet will mint to check if they receive the right amount of fee.

You can run scripts with the following command:

$ truffle exec scripts/name.scripts.js --network develop

Once the script is done the terminal shows us the following result:

Let's see what minting the NFTs has done to the balances of our accounts.

Accounts[0] is the owner address and only deployed the contracts, but the other accounts all minted some NFTs.

Our next step is to play games. We're going to play 140 games with accounts[3] to [9].

First is to set the wager, our Smart Contract will create a Game and returns the gameId of the newly created game. After we receive the gameId from the contract, we'll call the backend to play the game. Our backend will randomly choose the result of the game and calls the UpdateGame function, update the game state and set the result inside the contract, also our UpdateGame function sends the wager back when it's a tie and doubles the wager if the player wins the game. We take a 3.5% fee when the user loses or wins the game. From the fee 65% goes to the NFT vault. By playing with 1 ETH as wager we can make the following calculation:

(1 * 0.035) * 0.65 = 0.02275ETH

For every game we simulate and the player lose the game 0.02275ETH will be transferred to the NFT Vault.

When the player wins the game, we double the wager and take the 3.5% fee from the doubled wager. Again 65% goes to the NFT vault. By playing with 1 ETH as wager we can make the following calculation:

(2 * 0.035) * 0.65 = 0.0455ETH

If the player wins 0.0455ETH will be transferred to the NFT Vault and 1.9545ETH will be transferred back to the player.

If the result is a tie, no fee will be taken and the player received the wager back.

Our script will do the following: It simulates 140 games, played by 6 different players setting 1 ETH as a wager and playing different actions. After receiving a result from the Smart Contract the player sends a json object to the backend with our GameId. Our backend will update the game with the result and give a response back to the player.

Let's see how our script looks like:

const fetch = require("node-fetch");
const treasuryContract = artifacts.require("Treasury");

module.exports = function(callback) {
  (async () => {
    let counter = 0;
    let winCount = 0;
    let loseCount = 0;
    let tieCount = 0;
    const contractInstance = await treasuryContract.deployed();
    const accounts = await web3.eth.getAccounts()
    // 0 Rock, 1 Paper, 2 Scissors
    let action = 0;
    let player = accounts[3]; // 20 games
    while(counter <= 139) {
      if (counter > 19 && counter <= 50) {
        player = accounts[4]; // 30 games
        action = 1;
      } else if (counter > 50 && counter <= 75) {
        player = accounts[5]; // 25 games
        action = 2
      } else if (counter > 75 && counter <= 95) {
        player = accounts[6]; // 20 games
        action = 0;
      } else if (counter > 95 && counter <= 115) {
        player = accounts[7]; // 20 games
        action = 1;
      } else if (counter > 115 && counter <= 125) {
        player = accounts[8]; // 10 games
        action = 2;
      } else if (counter > 125) {
        player = accounts[9]; // 14 games
        action = 1;
      }

      try {
        await contractInstance.SetWager(action, {from: player, value: web3.utils.toWei("1")});
        const gameId = await contractInstance.GameIndexer.call();
        console.log(`Play game with game id: ${gameId.toString()}`);
        const playGame = async () => {
          try {
            return await fetch('http://localhost:5000/rps/play', {
              headers: {"Content-Type": "application/json; charset=utf-8"},
              method: 'POST',
              body: JSON.stringify({
                "gameAction": action,
                "walletAddress": player.toString(),
                "gameId_SC": Number(gameId.toString())
              })
            }).then(res => res.json());
          } catch(e) {
            await playGame();
          }
        }
        const response = await playGame();

        if (response.result === 0) {
          console.log('Player wins');
          winCount++;
        } else if (response.result === 2) {
          console.log("TIE");
          tieCount++;
        } else {
          console.log("Player loses");
          loseCount++;
        }
      } catch (error) {
        console.log(error);
      }
      console.log('\n');
      counter++;
    }

    console.log(`WINS: ${winCount}`);
    console.log(`LOSES: ${loseCount}`);
    console.log(`TIE: ${tieCount}`);
    callback();
  })();
}

We run the script with the following command:

$ truffle exec scripts/playGame.scripts.js --network develop

Our terminal shows us the following response:

Let's compare our balances before and after playing the game:

Before and after playing 140 games

We can calculate how much fee our contract has collected from these games by doing the following calculations:

(104 * 0.035) * 0.65 = 2.366ETH
(45 * 0.035) * 0.65 = 1.02375ETH 

2.366ETH + 1.02375ETH = 3.38975ETH

When we distribute our fee between the NFT holders the following calculation is made:

feePerAddress = vaultBalance / totalySupply * NFTPerWallet 

accounts[1] has 50 NFTs: 3.38975 / 550 * 50 = 0.308159090909091ETH
accounts[2] has 100 NFTs: 3.38975 / 550 * 100 = 0.616318181818182ETH
accounts[3] has 50 NFTs: 3.38975 / 550 * 50 = 0.308159090909091ETH
accounts[4] has 25 NFTs: 3.38975 / 550 * 25 = 0.154079545454546ETH
accounts[5] has 75 NFTs: 3.38975 / 550 * 75 = 0.431422727272727ETH
accounts[6] has 60 NFTs: 3.38975 / 550 * 60 = 0.369790909090909ETH
accounts[7] has 85 NFTs: 3.38975 / 550 * 85 = 0.523870454545455ETH
accounts[8] has 55 NFTs: 3.38975 / 550 * 55 = 0.338975000000000ETH
accounts[9] has 49 NFTs: 3.38975 / 550 * 49 = 0.301995909090909ETH

After distributing these fees, our accounts has to be incremented with these numbers. To save gas we distribute the fees in the backend instead of a Smart Contract function. Our function works as follows:

First we get all the NFT holders by looping through the totalSupply and get the owner of the tokenId.

When we have the list of owners we'll loop through the list and count how many NFTs one owner has. Then we'll calculate how much fee each owner will receive and then we'll call our Smart Contract to transfer the fee to the owner address.  

public async Task<bool> DistributeNftFee()
{
    // Treasury owner account
    var privateKey = _configuration.GetSection("ChainSettings:PrivateKey").Value;
    var wallet = new Wallet(privateKey, "").GetAccount(0);
    var account = new Account(wallet.PrivateKey, int.Parse(_configuration.GetSection("ChainSettings:ChainId").Value));
    
    // Get all NFT Hodlers
    var contract = _contractProvider.ConnectToNFTContract();
    var totalSupplyFunction = contract.GetFunction("totalSupply");
    var ownerOfFunction = contract.GetFunction("ownerOf");
    
    var totalSupply = await totalSupplyFunction.CallAsync<BigInteger>().ConfigureAwait(false);
    var hodlers = new List<string>();
    for (var i = 1; i <= totalSupply; i++)
    {
        var owner = await ownerOfFunction.CallAsync<string>(i).ConfigureAwait(false);
        hodlers.Add(owner);
    }

    // Get NFT Vault Balance
    var treasuryContract = _contractProvider.ConnectToContract();
    var transferFunction = treasuryContract.GetFunction("SendNFTFeeToAddress");
    var balanceFunction = treasuryContract.GetFunction("NFTVaultBalance");
    var nftVaultBalance = await balanceFunction.CallAsync<BigInteger>().ConfigureAwait(false);
    var vaultBalance = Web3.Convert.FromWei(nftVaultBalance);
    
    try
    {
        foreach (var holder in hodlers.GroupBy(x => x))
        {
            var feeForOwner = vaultBalance / (decimal)totalSupply * holder.Count();
            var fee = Web3.Convert.ToWei(feeForOwner);
            var dto = new object[] {holder.Key, fee};
            var estimateGas = await transferFunction.EstimateGasAsync(dto).ConfigureAwait(false);
            await transferFunction.SendTransactionAsync(account.Address, estimateGas, new HexBigInteger(0), dto).ConfigureAwait(false);
        }
    }
    catch (Exception e)
    {
        throw new Exception($"Error: {e.Message}");
    }
    return true;
}

Our Smart Contract function looks like this:

/**
* @dev Treasury Function to distribute the fees to NFT Holders
*/
function SendNFTFeeToAddress(address _receiver, uint _amount) nonReentrant TreasuryOwner() external {
    require(NFTVaultBalance > 0, "Error: NFTVaultBalance is 0");
    require(NFTVaultBalance >= _amount, "Error: NFTVaultBalance is less than _amount");
    NFTVaultBalance -= _amount;
    (bool success, ) = _receiver.call{value:_amount}('Transfer fee to NFT holder');
    require(success, "Error: Transfer to NFT holder failed");
    emit FeeDistributed(_receiver, _amount);
}

Our backend will automatically run the distributeNftFee once per month, but we're using a script for it:

const fetch = require("node-fetch");

module.exports = function(callback) {
  (async () => {
    try {
      const response =  await fetch('http://localhost:5000/rps/distribute-nft-fee')
        .then(res => res.json());
      console.log(response)
    } catch(e) {
      console.log(e)
    }
    callback();
  })();
}

Let's see if everything worked as expected:

$ truffle exec scripts/distribute.scripts.js --network develop

Let's compare the wallets before and after the fee distribution.

Balances before and after fee distribution

When we compare the balances we can see that all the accounts received a part of the NFT fee our contract distributes. It works perfectly!

Let's sum up

Our fee distribution depends on how many NFTs are minted and the amount of games played. To simulate the distribution we've written scripts to mint all the 550 NFts, playing 140 games to collect the fee and we covered how we implemented our fee distribution among NFT holders. An advantage of writing scripts is that we literally see what is happening.

For next time:

I'm very curious about Hardhat, another framework for developing Smart Contracts. So I'm thinking about doing the same project again with Hardhat as a development framework and comparing the two with each other. I think it can be really interesting to show the differences between the two frameworks. For now, happy coding!

Repo: https://github.com/JordyKingz/rock-paper-scissors