DEV Community

Cover image for Build a Multi-Payment DApp on Morph with Foundry
Azeez Abidoye
Azeez Abidoye

Posted on

Build a Multi-Payment DApp on Morph with Foundry

Hello Devs 👋

This tutorial provides a hands-on approach, guiding developers through building a decentralized application (DApp) capable of handling multiple payment types or currencies, while using Foundry—a popular smart contract development framework.

Prerequisites 📚

  • Node JS (v16 or later)
  • NPM (v6 or later)
  • Solidity
  • Metamask
  • Testnet ethers
  • JavaScript

Dev Tools 🛠️

  • Foundry
curl -L https://foundry.paradigm.xyz | bash
Enter fullscreen mode Exit fullscreen mode

Let's start hacking... 👨‍💻

Step 1: Create a new project directory

mkdir batchsender-dapp
Enter fullscreen mode Exit fullscreen mode
  • Navigate into the new project directory
cd batchsender-dapp
Enter fullscreen mode Exit fullscreen mode

Step 2: Initialize Foundry framework for smart contract development

✍️ Foundry creates a new git repository; we may prevent this by flagging the command.

forge init --no-git
Enter fullscreen mode Exit fullscreen mode

Foundry file tree:

  • script
  • src
  • test

✍️ Each of these folders contains a file. Delete them one by one to create a clean slate.

Step 3: Create the smart contract code

  • Navigate to the src directory
  • Create a new file named Batchsender.sol
  • Update Batchsender.sol with the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Batchsender {
    // Custom Error for mismatched recipients and amounts
    error MismatchedArrayLengths(
        uint256 recipientsLength,
        uint256 amountsLength
    );

    // Custom error for insufficient ethers
    error NotEnoughEth();

    // Custom Error for failed transfer
    error TransferFailed(address recipients, uint256 amounts);

    // Event to log each successful transfer
    event TokenSent(address indexed recipients, uint256 amounts);

    // Payable function for Ether transfer
    function sendToken(
        address payable[] calldata recipients,
        uint[] calldata amounts
    ) external payable {
        // Check if recipient length and amount length are the same
        if (recipients.length != amounts.length) {
            revert MismatchedArrayLengths(recipients.length, amounts.length);
        }

        // Calculate the total amount to be sent
        uint totalAmount = 0;
        for (uint i = 0; i < amounts.length; i++) {
            totalAmount += amounts[i];
        }

        // Ensure the sent amount is equal or greater than the total amount
        if (msg.value < totalAmount) {
            revert NotEnoughEth();
        }

        // Loop through recipients array to match recipients to amounts
        for (uint i = 0; i < recipients.length; i++) {
            (bool sendSuccess, ) = recipients[i].call{value: amounts[i]}("");
            if (!sendSuccess) {
                revert TransferFailed(recipients[i], amounts[i]);
            }

            // Emit event for each successful transfer
            emit TokenSent(recipients[i], amounts[i]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Compile the smart contract code

forge build
Enter fullscreen mode Exit fullscreen mode
  • Compilation result:
[⠊] Compiling...
[⠢] Compiling 1 files with Solc 0.8.27
[⠆] Solc 0.8.27 finished in 139.58ms
Compiler run successful!
Enter fullscreen mode Exit fullscreen mode

Step 5: Write solidity code for unit tests

  • Navigate to the test directory
  • Create a new file named Batchsender.t.sol
  • Update Batchsender.t.sol with the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Test, console} from "forge-std/Test.sol";
import {Batchsender} from "../src/Batchsender.sol";

contract BatchsenderTest is Test {
    Batchsender public batchsender;

    function setUp() public {
        // Deploy the contract before each test
        batchsender = new Batchsender();
    }

    function test_sendToken() public {
        address payable[] memory recipients = new address payable[](2);
        uint[] memory amounts = new uint[](2);
        recipients[0] = payable(0x6c5fa1b41990f4ee402984Bcc8Bf6F4CB769fE74);
        recipients[1] = payable(0x55829bC84132E1449b62607B1c7bbC012f0326Ac);
        amounts[0] = 100; //wei
        amounts[1] = 200; //wei
        batchsender.sendToken{value: 300}(recipients, amounts);
        assertEq(address(recipients[0]).balance, amounts[0]);
        assertEq(address(recipients[1]).balance, amounts[1]);
    }
}
Enter fullscreen mode Exit fullscreen mode

✍️ You can visit vanity-eth.tk to generate new wallet addresses for your test.

Step 6: Running the test

forge test --match-path test/Batchsender.t.sol 
Enter fullscreen mode Exit fullscreen mode
  • Test result should look like this:
[⠊] Compiling...
[⠘] Compiling 24 files with Solc 0.8.27
[⠃] Solc 0.8.27 finished in 733.97ms
Compiler run successful!

Ran 1 test for test/Batchsender.t.sol:BatchsenderTest
[PASS] test_sendToken() (gas: 90741)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.33ms (1.46ms CPU time)

Ran 1 test suite in 151.38ms (8.33ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Enter fullscreen mode Exit fullscreen mode

Step 7: Deploying smart contract to Morph Holesky testnet

  • Create a .env file in the project directory and add three (3) environment variables.
MORPH_RPC_URL="https://rpc-quicknode-holesky.morphl2.io"
DEV_PRIVATE_KEY="0x-insert-your-private-key"    // Prefix with 0x
CONTRACT_ADDRESS=""
Enter fullscreen mode Exit fullscreen mode

✍️ The CONTRACT_ADDRESS information will be added after deployment.

  • Navigate to the script directory and create a new file named Batchsender.s.sol

  • Add the following code for smart contract deployment

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

import {Script, console} from "forge-std/Script.sol";
import {Batchsender} from "../src/Batchsender.sol";

contract BatchsenderScript is Script {
    Batchsender public batchsender;

    function setUp() public {}

    function run() public {
        // Save Private key as variable for reusability
        uint privateKey = vm.envUint("DEV_PRIVATE_KEY");

        // start deployment...with Private Key
        vm.startBroadcast(privateKey);

        // Log Account to the console
        address account = vm.addr(privateKey);
        console.log("Deployer Account address: ", account);

        batchsender = new Batchsender();

        vm.stopBroadcast();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Running the deployment script

  • Load the environment information into the CLI
source .env
Enter fullscreen mode Exit fullscreen mode
  • Confirm the deployer's account address
forge script script/Batchsender.s.sol:BatchsenderScript
Enter fullscreen mode Exit fullscreen mode
  • This execution will return the wallet address associated with the deployer's private key.
[⠊] Compiling...
[⠑] Compiling 2 files with Solc 0.8.27
[⠘] Solc 0.8.27 finished in 650.26ms
Compiler run successful!
Script ran successfully.
Gas used: 259115

== Logs ==
  Deployer Account address:  0x4E1856E40D53e2893803f1da919F5daB713B215c
Enter fullscreen mode Exit fullscreen mode
  • Simulate the smart contract
forge script script/Batchsender.s.sol:BatchsenderScript --rpc-url $MORPH_RPC_URL
Enter fullscreen mode Exit fullscreen mode
  • Successful simulation result:
[⠊] Compiling...
No files changed, compilation skipped
Script ran successfully.

== Logs ==
  Deployer Account address:  0x4E1856E40D53e2893803f1da919F5daB713B215c

## Setting up 1 EVM.

==========================

Chain 2810

Estimated gas price: 0.15063575 gwei

Estimated total gas used for script: 340061

Estimated amount required: 0.00005122534378075 ETH

==========================

SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet configuration(s) to the previous command. See forge script --help for more.
Enter fullscreen mode Exit fullscreen mode

✍️ This execution automatically creates a new directory named broadcast in the project for seamless smart contract deployment.

  • Final execution for smart contract deployment
forge script script/Batchsender.s.sol:BatchsenderScript --rpc-url $MORPH_RPC_URL --broadcast --private-key $DEV_PRIVATE_KEY --legacy
Enter fullscreen mode Exit fullscreen mode
  • The deployment result should look like this:
[⠊] Compiling...
No files changed, compilation skipped
Script ran successfully.

== Logs ==
  Deployer Account address:  0x4E1856E40D53e2893803f1da919F5daB713B215c

## Setting up 1 EVM.

==========================

Chain 2810

Estimated gas price: 0.14963575 gwei

Estimated total gas used for script: 340061

Estimated amount required: 0.00005088528278075 ETH

==========================

##### 2810[Success]Hash: 0xe65413c0b8ff406c18c33b77c385bc3d46bcd9305030dd49aa2277d3c90bf69a
Contract Address: 0x8D69CCBf55ce078d9c9838a8861e0c827Dd4f2ff
Block: 11252084
Paid: 0.0000391521939875 ETH (261650 gas * 0.14963575 gwei)

✅ Sequence #1 on 2810 | Total Paid: 0.0000391521939875 ETH (261650 gas * avg 0.14963575 gwei)


==========================

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Enter fullscreen mode Exit fullscreen mode

✍️ Copy the contract address to populate the CONTRACT_ADDRESS environment variable.

Step 9: Integrating the frontend with NextJs

npx create-next-app@latest 
Enter fullscreen mode Exit fullscreen mode

✍️ Name your app frontend and select the default configuration for the rest of the prompt.

Step 10: Install plugins for development

Install the three (3) dependencies needed for the development of the client-side of the application.

npm install ethers bootstrap react-csv-importer
Enter fullscreen mode Exit fullscreen mode

Step 12: Creating the frontend component

  • Open the src directory
  • The frontend component is structured in the app/page.js file
  • To start afresh, replace the default code with the following:
"use client"

export default function Home() {
  return ();
}
Enter fullscreen mode Exit fullscreen mode
  • Update the imports:
"use client"

import { useState } from "react";
import { Importer, ImporterField } from "react-csv-importer";
import { ethers, Contract } from "ethers";

export default function Home() {
  return ();
}
Enter fullscreen mode Exit fullscreen mode
  • Import the contract ABI:
// Import contract ABI
import {abi as contractABI} from '../../../out/Batchsender.sol/Batchsender.json';
Enter fullscreen mode Exit fullscreen mode
  • Store the contract address in a variable:
// Contract address
const contractAddress = "0x8D69CCBf55ce078d9c9838a8861e0c827Dd4f2ff";
Enter fullscreen mode Exit fullscreen mode
  • Create an object variable for the blockchain explorer:
// Blockchain Explorer object
const blockchainExplorerUrl = {
  2810: "https://rpc-quicknode-holesky.morphl2.io",
};
Enter fullscreen mode Exit fullscreen mode
  • Update the state variables:
// Some code...

export default function Home() {

  // State variables
  const [payments, setPayments] = useState(undefined);
  const [sending, setSending] = useState(false);
  const [blockchainExplorer, setBlockchainExplorer] = useState();
  const [error, setError] = useState(false);
  const [transaction, setTransaction] = useState(false);

  return ();
}
Enter fullscreen mode Exit fullscreen mode
  • Create the function for sending payments
export default function Home() {
  // Some state variable codes...

    // Function for sending payments
    const sendPayments = async () => {
        // Connect to Metamask
        const provider = new ethers.BrowserProvider(window.ethereum);
        const signer = await provider.getSigner();

        const chainIdBigInt = (await provider.getNetwork()).chainId;
        const chainId = Number(chainIdBigInt); // convert to interger;

        setBlockchainExplorer(blockchainExplorerUrl[chainId.toString()]);

        // Show feedback to users
        setSending(true);

        // Format arguements for smart contract => Convert CSV row to column
        const { recipient, amount, total } = payments.reduce(
          (acc, val) => {
            acc.recipient.push(val.recipient);
            acc.amount.push(val.amount);
            acc.total += Number(val.amount);
            return acc;
          },
          {
            recipient: [],
            amount: [],
            total: 0,
          }
        );

        // Send transaction
        const batchsenderContract = new Contract(
          contractAddress,
          contractABI,
          signer
        );

        try {
          const transaction = await batchsenderContract.sendToken(
            recipient,
            amount,
            {
              value: total,
            }
          );
          const transactionReceipt = await transaction.wait();
          setTransaction(transactionReceipt.hash);
        } catch (error) {
          console.log(error);
          setError(true);
        }
      };

  return ( );
}
Enter fullscreen mode Exit fullscreen mode
  • Update the JSX component structure:
export default function Home() {

    // Some state variable and function codes...

    return (
        <>
        <div className="container-fluid mt-5 d-flex justify-content-center">
          <div id="content" className="row">
            <div id="content-inner" className="col">
              <div className="text-center">
                <h1 id="title" className="fw-bold">
                  BATCHSENDER
                </h1>
                <p id="sub-title" className="mt-4 fw-bold">
                  Send multiple payments <br />{" "}
                  <span>in just one transaction</span>
                </p>
              </div>
              <Importer
                dataHandler={(rows) => setPayments(rows)}
                defaultNoHeader={false} // optional, keeps "data has headers" checkbox off by default
                restartable={false} // optional, lets user choose to upload another file when import is complete
              >
                <ImporterField name="recipient" label="recipient" />
                <ImporterField name="amount" label="amount" />
                <ImporterField name="asset" label="asset" />
              </Importer>
              <div className="text-center">
                <button
                  className="btn btn-primary mt-5"
                  onClick={sendPayments}
                  disabled={sending || typeof payments === "undefined"}
                >
                  Send transactions
                </button>
              </div>
              {sending && (
                <div className="alert alert-info mt-4 mb-0">
                  Please wait while your transaction is being processed...
                </div>
              )}
              {transaction && (
                <div className="alert alert-success mt-4 mb-0">
                  Congrats! Transaction processed successfully. <br />
                  <a
                    href={`${blockchainExplorer}/${transaction}`}
                    target="_blank"
                  >{`${transaction.substr(0, 20)}...`}</a>
                </div>
              )}
              {error && (
                <div className="alert alert-danger mt-4 mb-0">
                  Oops...there was an error. Please try again later!
                </div>
              )}
            </div>
          </div>
        </div>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Step 13: Adding styles to the component

  • Navigate to the src/app directory
  • Create a new file named style.css
  • Update style.css with the following code:
#content {
    width: 700px;
  }

  #content-inner {
    background-color: rgba(240, 240, 240);
    border-radius: 10px;
    padding: 1em;
  }

  #title {
    font-family: "Permanent Marker", cursive;
    font-size: 2em;
    font-style: normal;
    font-weight: 400;
  }

  #sub-title {
    font-size: 1.5em;
  }

  #sub-title span {
    border-bottom: 5px solid #085ed6;
  }

  #CSVImporter_Importer {
    margin-top: 3em;
  }
Enter fullscreen mode Exit fullscreen mode

Step 14: Configure the layout structure

  • Open the layout.js file
  • Replace the code with the following:
import "bootstrap/dist/css/bootstrap.min.css";
import "react-csv-importer/dist/index.css";
import "./style.css";

export const metadata = {
  title: "Batchsender",
  description: "Make multiple crypto payments in one click",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
        <link
          href="https://fonts.googleapis.com/css2?family=Permanent+Marker&display=swap"
          rel="stylesheet"
        />
      </head>

      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 15: Interacting with the DApp

  • To start interacting with the DApp, launch project in the browser
npm run dev
Enter fullscreen mode Exit fullscreen mode

Batchsender UI

  • Create an Excel spreadsheet containing the information of your transaction in this format:
Recipient Amount Asset
Wallet Addr1 50 ETH
Wallet Addr2 100 ETH
Wallet Addr3 200 ETH
  • Export this file to a Comma Separated Values .csv file
  • Drag and drop your .csv file on the marked area or click to select one from your files

Batchsender data load-up

  • Select Choose columns to load-up the information
  • Click the Import button to import the information to the DApp for processing

Batchsender data import

Batchsender data load-up success

  • Select Send transactions to make payments in one click

Batchsender metamask confirmation

Batchsender transaction successful

Conclusion

In this complete guide, we have successfully used the most recent blockchain development tools to build and deploy a dynamic decentralized application on the Morph blockchain, which is a Layer 2 solution that combines the benefits of both zK and Optimistic rollups for secure and rapid transactions.
Happy hacking... 🎉

Top comments (0)