DEV Community

Cover image for Full Stack Ethereum and Dapp Development. A comprehensive guide: 2024
Azeez Abidoye
Azeez Abidoye

Posted on

Full Stack Ethereum and Dapp Development. A comprehensive guide: 2024

Building Full stack Dapps with Solidity, Hardhat, React, EthersJs, Mocha and Chai.

Building decentralized applications on the Ethereum blockchain and other EVM-compatible blockchain networks such as Polygon, Avalanche, Celo and many others comes handy in this tutorial.
At the conclusion of this comprehensive guide, you will understand every step required for the concise development and effective deployment of smart contracts, as well as the proper integration of the client-side with React.

After considering numerous options for the tutorial's heading, I decided to include the year of publishing to raise awareness about the tutorial's goals and objectives. Blockchain is a nascent and rapidly expanding technology with several modifications being made to the development method on a daily basis. I'm sure some developers have seen various tutorials that depict comparable teaching, but the methodologies may have altered over time as a result of the changes brought to blockchain development every now and then.

Based on my knowledge and experience as a blockchain developer, the most popular stack being utilized by Web3 developers for building a full stack decentralized application with Solidity includes:

  1. Ethereum Development Environment - Hardhat
  2. Ethereum Web Client Library - Ethers.js
  3. Javascript Test Framework - Mocha
  4. Client-side framework - React
  5. Oracle network - Chainlink
  6. API Layer - The Graph Protocol

To further understand the concept behind the stack mentioned above, I recommend you check out this tutorial.

As I previously stated, the Web3 industry is always growing, with new updates being provided on a daily basis. As a result, I have compiled the most recent complete strategy to simplify blockchain development and will hopefully update this guide as needed.

The code for this tutorial is located here:

GitHub logo azeezabidoye / messenger-dapp

Full Stack Ethereum and Dapp Development. A comprehensive guide: 2024

Full Stack Ethereum and Dapp Development. A comprehensive guide: 2024

Checkout the tutorial on Dev.to

What we intend to achieve

In this tutorial, we are going to learn how to:

  • Create Dapp using Hardhat framework
  • Write Solidity smart contract
  • Deploy to Celo Alfajores testnet
  • Test our Dapp using Mocha and Chai
  • Create UI components with React
  • Interact with our Dapp

The Stack breakdown

  1. Hardhat: To build smart contracts, you must compile your Solidity code into code that can be easily read and run by the client-side application, deploy your contracts, perform tests, and debug Solidity code without dealing with a live environment.
    Hardhat is an Ethereum development environment and framework created specifically for this purpose. We will build our full stack Dapp using the Hardhat framework.

  2. React: React is an excellent frontend Javascript library for developing web applications, user interfaces, and UI components.
    React and its extensive ecosystem of metaframeworks, including Next.Js, Gatsby, Blitz.Js and others support a wide range of deployments, including classic Single Page Applications (SPAs), static site generators, server-side rendering, and a combination of the three.
    We will construct our Dapp by combining React with Hardhat as the client-side library.

  3. Ethers.js: The Ethers.js library will serve the purpose of the Ethereum web client library in the development of our Dapp.
    In our React application, we'll need to interact with the deployed smart contracts. We'll need a means to read the data and send new transactions.
    Ethers.js intends to provide a comprehensive library for the Ethereum blockchain and its ecosystem, covering client-side Javascript applications such as React, Vue, and Angular.

  4. Mocha: According to the Mocha website, it is a Javascript framework that makes asynchronous testing simple and fun.
    Before we deploy our Dapp to the blockchain network, we need to execute a series of tests to ensure that the smart contracts function properly.
    Mocha tests run serially, allowing for flexible and reliable reporting; this is the library we will use in our projects.

  5. Metamask is a Google Chrome extension that injects itself into Javascript code anytime your Dapp frontend is loaded.
    This Chrome extension assists with account administration and connects the current user to the blockchain.
    Once a user has connected their Metamask wallet, you as a developer can interact with the globally available Ethereum API (windows.ethereum), which identifies users of web3-compatible browsers (such as Metamask), and whenever you request a transaction signature, Metamask will prompt the user to confirm it immediately.

  6. Chainlink: Chainlink bridges a major gap in the blockchain ecosystem by enabling smart contracts to safely communicate with real-world apps, increasing their use cases and effectiveness.
    Chainlink is a significant decentralized oracle network that enables data interchange between on-chain and off-chain applications. It is essential for providing real-world information to blockchain networks.

  7. The Graph: Because most blockchain applications, such as Ethereum, are difficult and time-consuming to read data from the chain, both companies and individuals create their own centralized indexing servers and service API requests from them. Unfortunately, this requires a significant investment in engineering and hardware, as well as a compromise of the security features essential for decentralization.
    The Graph Protocol is an indexing protocol for searching blockchain data that allows for the development of completely decentralized apps while also offering a rich GraphQL query layer for application consumption.

The tutorial is absolutely beginner-friendly thereby for some reasons we will not discuss Chainlink and The Graph. However, there will be reasons to reference these tools in tutorials in the future.

Prerequisites

  • NodeJs
  • Metamask
  • Testnet ethers

Dev tool

  • Yarn


npm install -g yarn


Enter fullscreen mode Exit fullscreen mode

Let's start hacking...

Step #1: Create new React project

To get started, create a new React application



npm create vite@latest project_name --template react


Enter fullscreen mode Exit fullscreen mode

In our case, we will name the application messenger-dapp. Therefore, you can run:



npm create vite@latest messenger-dapp --template react


Enter fullscreen mode Exit fullscreen mode

Follow the prompt, choose React and finally choose Javascript.

✍️ Your React app is created with the project-name specified.

Navigate to the new project directory.



cd messenger-dapp


Enter fullscreen mode Exit fullscreen mode

Step #2: Install Hardhat package as a dependency

You can use either Yarn or NPM but for the purpose of this tutorial, I will recommend using Yarn



yarn add hardhat


Enter fullscreen mode Exit fullscreen mode

Step #3: Configure Hardhat Ethereum Development environment



npx hardhat init


Enter fullscreen mode Exit fullscreen mode

Follow the prompt and select Create a Javascript project.
Press “y" to agree to other options and continue.

✍️ Hardhat will automatically install all necessary packages for your project.

Hardhat folder structure

  • Hardhat.config.cjs file: Serves as the entry point of our development; includes every configuration, plugins, and custom tasks.
  • Contracts directory: Directory for all Solidity contract codes.
  • Test: Directory for test scripts.

Hardhat folder structure

Delete all the files in both Contracts and Test directories. This is to ensure that we have a clean slate for our code and development.

Step #4: Setup environment variables

Environment variables are predetermined values that are typically used to provide the ability to configure the way programs, applications and services will behave.
For this tutorial, there are two basic environment variables we will be needing for our development. They are; an Infura API key which will help us to run our node during deployment and our Private key.

Getting started with Infura API key

Let me quickly walk you through the process of getting your first API key on the Infura platform

  • Visit infura.io
  • Get started by setting up an Infura account
  • Select "Create new API key" on the dashboard. Follow the first two steps in this documentation for guidance
  • Click on the API key name to see all endpoints provided
  • Check your desired network endpoints and "Save Changes"

Infura All Endpoints page

  • Select "Active Endpoints" in the navigation.
  • Copy the HTTPS URL provided for your testnet.

Infura Active Endpoint page

BONUS: How to Integrate Celo Alfajores Testnet with Metamask

  • Open Metamask
  • Select the network dropdown at the top left corner
  • Turn on the “Show testnet” option to see test networks

Metamask network dropdown

  • To add Celo Alfajores testnet to the list; visit chainlist.org
  • Endeavour to check the “Include Testnets” checkbox
  • Search for “Celo Alfajores” in the search field
  • Click “Add to Metamask” on the network grid

Chainlist network page

  • Follow the prompt and finally “Switch network”
  • Get free tokens from Celo faucet; visit faucet.celo.org
  • Paste wallet address into the “Account address” field
  • Click on “Faucet” button

Celo faucet

  • Wait for process to be completed > 🎉 Boom: Your account has been funded with 0.5 Celo token

Celo Token balance on Metamask

Configure Private key

  • Open Metamask
  • Click the stacked dots at the top right corner
  • Select "Account details"

Metamask account details

  • Select "Show private key"

Show Private key

  • Enter your password to continue

Insert password on Metamask

  • Click and hold "Hold to reveal Private Key" button to reveal your Private key

Hold to reveal private key

  • Copy your Private key for configuration

Copy private key

Step #5: Configure Hardhat for Dapp development

Navigate to the hardhat.config.cjs file and configure the network for the testnet.



  networks: {
    alfajores: {
      chainId: 44787,
      url: "https://celo-alfajores.infura.io/v3/1644a8878efe4a5e8b1eabc92564e725", // Insert Infura Celo Url here
      accounts: [a77868cba9d67ed2854547cdebb3e30c52cabaa1c4646beefing786c811c5fc], // Insert Metamask Private key here
    }
  }


Enter fullscreen mode Exit fullscreen mode

Endeavour to prefix your Private key with an 0x and wrap it in quotes to avoid errors.



  networks: {
    alfajores: {
      // Code here
      accounts: ["0xa77868cba9d67ed2854547cdebb3e30c52cabaa1c4646beef008e786c811c5fc"], // Insert Metamask Private key here
    }
  }


Enter fullscreen mode Exit fullscreen mode

Step #6: Create the smart contract file

Navigate to the Contracts directory and create a new file for the Solidity code as Messenger.sol and update the file with the following code:



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

import "hardhat/console.sol";

contract Messenger {
    string message;

    constructor(string memory _message) {
        console.log("Deploying Messenger with message:", _message);
        message = _message;
    }

    function getMessage() public view returns (string memory) {
        return message;
    }

    function setMessage(string memory _message) public {
        console.log("Changing message from '%s' to '%s'", message, _message);
        message = _message;
    }
}


Enter fullscreen mode Exit fullscreen mode

About the contract

This smart contract is simple. The contract has a variable that was defined in the global scope but assigned a value in the function constructor.
The function constructor is only called when the contract is deployed, therefore it sets the message variable. It also exposes a function (getMessage) that can be used to retrieve the message.
Additionally, another function called (setMessage) is available, which allows users to modify the message variable. When this contract gets deployed on the Ethereum blockchain, users will be able to interact with these methods.

Reading and writing to the Ethereum blockchain

There are two main ways to interact with an Ethereum smart contract: reading and writing. Reading is a non-transactional activity, whereas writing is transactional. In the smart contract shown above, the (getMessage) function is considered reading, whereas the (setMessage) method is considered writing or transactional.

You do not need to carry out a transaction if you are merely reading from the blockchain and not modifying or updating anything; there will be no gas or cost involved. The function you request is then carried out exclusively by the node to which you are connected, thus you do not have to pay for gas and reading is free.

However, while writing or initializing a transaction, you must pay for it to be included in the blockchain. To make this work, you must pay gas, which is the cost or price necessary to properly complete a transaction or execute a contract on the Ethereum blockchain.

From our client-side application, we will communicate with the smart contract using the ethers.js library, the contract address, and the ABI generated by hardhat from the contract.

Step #7: Compile the contract

Compiling a smart contract involves using the contract's source code to generate its bytecode and the contract Application Binary Interface (ABI). The Ethereum Virtual Machine (EVM) executes the bytecode to understand and execute the smart contract.

After we run the command to compile the smart contract, Hardhat generates a directory named artifacts in our root directory.
We can specify where the artifacts directory should be simply by adding a few options to the hardhat.config.cjs file.



  paths: {
    artifacts: "./src/artifacts",
  }


Enter fullscreen mode Exit fullscreen mode

⚠️ Specifying a directory for the auto-generated ABI has no bearing on the smart contract compilation process. It's just a good practice to place the ABI in the src folder because that's where our client-side code will be created.

As of now, your Hardhat configuration should look like this:



module.exports = {
  solidity: "0.8.24",
  paths: {
    artifacts: "./src/artifacts"
  },
  networks: {
    alfajores: {
      chainId: 44787,
      url: "https://celo-alfajores.infura.io/v3/1644a8878efe4a5e8b1eabc92564e725", // Insert Infura Celo Url here
      accounts: ["0xa77868cba9d67ed2854547cdebb3e30c52cabaa1c4646beefinge786c811c5fc"], // Insert Metamask Private key here
    }
  }
};


Enter fullscreen mode Exit fullscreen mode

What is an ABI?

ABI stands for application binary interface. You might consider it as an interface between your client-side application and the Ethereum blockchain, where the smart contract with which you will be interacting with is created.

Now that we've covered the fundamentals of smart contracts and are familiar with ABIs, let's create one for our project.



yarn hardhat compile


Enter fullscreen mode Exit fullscreen mode

✍️ Watch out for the artifacts which is automatically added to the src directory of your project.

Step #8: Configure the Dapp for deployment

This is the most important stage of Dapp development; a few settings must be completed for a timely and effective deployment.

Create a folder for deployment scripts in the root directory



mkdir deploy


Enter fullscreen mode Exit fullscreen mode

Create a file for the deployment scripts in the deploy directory with a numbered naming structure. e.g 00-deploy-messenger.cjs

Install an Hardhat plugin as a package for deployment



yarn add hardhat-deploy --dev


Enter fullscreen mode Exit fullscreen mode

Import hardhat-deploy package to the hardhat-config.cjs file



require("hardhat-deploy")


Enter fullscreen mode Exit fullscreen mode

Install hardhat-deploy-ethers to override the @nomiclabs/hardhat-ethers package



yarn add --dev @nomiclabs/hardhat-ethers@npm:hardhat-deploy-ethers


Enter fullscreen mode Exit fullscreen mode

✍️ The command above allows Ethers to keep track of and remember all of the multiple deployments that we perform within our contract.

Set up a deployer account in the hardhat-config.cjs file



networks: {
  // Code Here
},
namedAccounts: {
  deployer: {
    default: 0,
  }
}


Enter fullscreen mode Exit fullscreen mode

Update the 00-deploy-messenger.cjs file with the following code to deploy the Messenger contract



module.exports = async ({ getNamedAccounts, deployments }) => {
  const { deploy } = deployments;
  const { deployer } = await getNamedAccounts();
  await deploy("Messenger", {
    contract: "Messenger",
    from: deployer,
    args: ["Hello Devs...this is simple and fun"], // The message value in the function constructor
    log: true, // Logs statements to console
  });
};
module.exports.tags = ["Messenger"];


Enter fullscreen mode Exit fullscreen mode

Step #9: Deploy contract to testnet

Now, we can execute the deploy script and instruct the CLI that we want to deploy to Celo Alfajores test network.



yarn hardhat deploy --network alfajores


Enter fullscreen mode Exit fullscreen mode

Once this script is executed, the smart contract should be deployed to the Celo Alfajores test network, enabling us to interact with it.

The output of the CLI should look like this:



deploying "Messenger" (tx: 0x59ef946ed3b481f78ea04929e4a1724aeccf7a3598fe7900e269e05ed90d4385)...: deployed at 0xAf22bD61d2206D22050C524003017817DebE61e4 with 633051 gas
✨  Done in 30.37s.


Enter fullscreen mode Exit fullscreen mode

And here is the contract address:



deployed at 0xAf22bD61d2206D22050C524003017817DebE61e4


Enter fullscreen mode Exit fullscreen mode

✍️ Observe how the token balance has changed. Gas fee for the Dapp's deployment have been deducted from the token balance. We would encounter more of this anytime we made transactional requests to the blockchain.

Token balance on Metamask after deployment

Step #10: Create and connect UI components with React

In this tutorial, we will create a basic UI component using React. To get you started, we'll focus on essential functions and a few CSS styles.

Let us briefly explore the two major goals of our React application:

  1. Retrieve the current value of message from the smart contract.
  2. Allow a user to update the value of the message.

Here are the few steps we need to take to achieve these goals:

  1. Create an input field and some local state to handle the input value (to update the message).
  2. Allow the application to connect to the user's MetaMask account for signing transactions.
  3. Create functions that read and write to the smart contract.

Open the src/App.jsx file and update it with the following code, set the value of messengerContractAddress to the address of your smart contract:



import "./App.css";
import React from "react";
import { useState } from "react";
import { ethers, BrowserProvider } from "ethers";

// Import the json-file from the ABI
import Messenger from "./artifacts/contracts/Messenger.sol/Messenger.json";

// Store the contract address in a variable
const messengerContractAddress = "your-contract-address"; // Deployed to testnet

const App = () => {
    // Store message in a local state
    const [message, setMessageValue] = useState();

    // Request access to User's MetaMask account
    const requestAccount = async () => {
        await window.ethereum.request({ method: "eth_requestAccounts" });
    };

    // Function for retrieving message value from smart contract.
    const getMessage = async () => {
        if (typeof window.ethereum !== "undefined") {
        const web3Provider = new ethers.BrowserProvider(window.ethereum);
        const contract = new ethers.Contract(
            messengerContractAddress,
            Messenger.abi,
            web3Provider
        );
        try {
            const data = await contract.getMessage();
            console.log(`Data: ${data}`);
        } catch (error) {
            console.error(error);
        }
        }
    };

    // Function for updating message value on smart contract
    const setMessage = async () => {
        if (!message) return;
        if (typeof window.ethereum !== "undefined") {
        await requestAccount();
        const web3Provider = new ethers.BrowserProvider(window.ethereum);
        const signer = await web3Provider.getSigner();
        const contract = new ethers.Contract(
            messengerContractAddress,
            Messenger.abi,
            signer
        );
        const transaction = await contract.setMessage(message);
        await transaction.wait();
        setMessageValue("");
        getMessage();
        }
  };

  return (
    <div className="container">
      <button onClick={getMessage}>Message</button>
      <button onClick={setMessage}>Send message</button>

      <input
        onChange={(e) => setMessageValue(e.target.value)}
        placeholder="Write your message here..."
      />
    </div>
  );
};

export default App;


Enter fullscreen mode Exit fullscreen mode

Let's add some styles...

Update the App.css file with the following CSS code:



#root {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

/* styles.css */
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

button {
  background-color: #4caf50; /* Green */
  border: none;
  color: white;
  padding: 10px 20px;
  text-align: center;
  text-decoration: none;
  display: inline-block;
  font-size: 16px;
  margin: 10px;
  cursor: pointer;
  border-radius: 5px;
  transition: background-color 0.3s ease;
}

button:hover {
  background-color: #45a049;
}

input {
  padding: 10px;
  margin: 10px;
  width: 300px;
  border: 1px solid #ccc;
  border-radius: 5px;
  font-size: 16px;
  transition: border-color 0.3s ease;
}

input:focus {
  border-color: #4caf50;
  outline: none;
}


Enter fullscreen mode Exit fullscreen mode

Next: run the React app:



yarn run dev


Enter fullscreen mode Exit fullscreen mode

Dapp interface

Now, you can interact with your Dapp. Ensure to keep your console open to see results.

Metamask transaction request

Data result in the console

Step #11: Writing unit tests with Mocha and Chai

Unit testing applies to everything we want to test, whether it's a class, a function, or a single line of code.
In this article, we will look at unit testing our Solidity code using Mocha, a lightweight Nodejs framework, and Chai, a Test-driven development (TDD) assertion library for Node.
Both Mocha and Chai use NodeJs, and the browser supports asynchronous testing. Although Mocha may be used with any assertion library, it is most usually combined with Chai.

Let's initiate some tests for the deployment of the smart contract and the two functions it contains.

First, ensure you have the necessary dependencies installed:



yarn add mocha chai@4.3.7 --dev


Enter fullscreen mode Exit fullscreen mode

Navigate to the test directory and create a new file as messenger-test.cjs.

Add the following code to test/messenger-test.cjs:



const { ethers } = require("hardhat");
const { expect } = require("chai");

describe("Messenger", function () {
  let MessengerFactory, messenger;

  beforeEach(async function () {
    MessengerFactory = await ethers.getContractFactory("Messenger");
    messenger = await MessengerFactory.deploy(
      "Hello devs...we love EVM development"
    );
    await messenger.waitForDeployment();
  });

  describe("Deployment", function () {
    it("Should set the correct initial message", async function () {
      expect(await messenger.getMessage()).to.equal(
        "Hello devs...we love EVM development"
      );
    });
  });

  describe("SetMessage", function () {
    it("Should change the message when called", async function () {
      await messenger.setMessage("We love building Dapps");
      expect(await messenger.getMessage()).to.equal("We love building Dapps");
    });

    it("Should emit console log on message change", async function () {
      const transaction = await messenger.setMessage("Happy hacking...");
      await transaction.wait();
    });
  });
});


Enter fullscreen mode Exit fullscreen mode

Navigate to your terminal and run:



yarn hardhat test


Enter fullscreen mode Exit fullscreen mode

The result of your test should pass like this:



  Messenger
    Deployment
Deploying Messenger with message: Hello devs...we love EVM development
      ✔ Should set the correct initial message
    SetMessage
Deploying Messenger with message: Hello devs...we love EVM development
Changing message from 'Hello devs...we love EVM development' to 'We love building Dapps'
      ✔ Should change the message when called
Deploying Messenger with message: Hello devs...we love EVM development
Changing message from 'Hello devs...we love EVM development' to 'Happy hacking...'
      ✔ Should emit console log on message change


  3 passing (412ms)

✨  Done in 1.78s.


Enter fullscreen mode Exit fullscreen mode

Big ups to you 👍

Congratulations if you've made it this far, and I commend your determination and desire to learn Web3 development. This is only a basic approach to the development, and I hope you use these techniques whenever you create a decentralized application for Ethereum and other EVM-based blockchain networks.
Although we have addressed certain elements required for effective development, there is something more we must do as professionals.

BONUS: Secure your environment variables

Remember, in Step #4 of this tutorial, we set up our environment variables, which are third-party components required for the creation and deployment of our smart contract.
It is critical that we safeguard them; if the codebase is accidentally shared or made public, the embedded secrets are revealed, resulting in a possible security compromise. Environment variables, on the other hand, are kept on the server and not exposed in the code, minimizing the danger of exposure.

We must protect the API endpoint we got from Infura and our Metamask Private key.

Install the dependency module that loads environment variables from a .env file:



yarn add dotenv --dev


Enter fullscreen mode Exit fullscreen mode

Create a new file in the root directory of the project as .env.

Add two new variables to the .env file with their values as follows:



PRIVATE_KEY="a77868cba9d67ed2854547cdebb3e30c52cabaa1c4646beefinge786c811c5fc"
INFURA_ALFAJORES_URL="https://celo-alfajores.infura.io/v3/1644a8878efe4a5e8b1eabc92564e725"


Enter fullscreen mode Exit fullscreen mode

Import the dotenv module to hardhat-config.cjs for configuration



require("dotenv").config()


Enter fullscreen mode Exit fullscreen mode

Replicate the two environment variables in hardhat-config.cjs file



const { PRIVATE_KEY, INFURA_ALFAJORES_URL } = process.env;


Enter fullscreen mode Exit fullscreen mode

Finally, your hardhat-config.cjs should be detailed as follows:



require("@nomicfoundation/hardhat-toolbox");
require("hardhat-deploy");
require("dotenv").config();

const { PRIVATE_KEY, INFURA_ALFAJORES_URL } = process.env;

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.24",
paths: {
artifacts: "./src/artifacts",
},
networks: {
alfajores: {
chainId: 44787,
url: INFURA_ALFAJORES_URL,
accounts: [PRIVATE_KEY],
},
},
namedAccounts: {
deployer: {
default: 0,
},
},
};

Enter fullscreen mode Exit fullscreen mode




Conclusion

Finally, we covered the fundamentals of Ethereum and Dapp development in great depth. Let's take a brief look at what we've learnt thus far;

  1. ✅ Built Dapp using Hardhat framework
  2. ✅ Developed Solidity smart contract.
  3. ✅ Deployed to Celo Alfajores testnet.
  4. ✅ We tested our DApp.
  5. ✅ Built a client-side application with React.
  6. ✅ Interacted with the Dapp.
  7. ✅ Additionally, secure the environment variables.

In my future tutorials, I'll go over more complex smart contract development, as well as how to use Chainlink to bridge communication between on-chain and off-chain networks, as well as how to deploy smart contracts as subgraphs to expose a GraphQL API and implement things like pagination and full text search.

If you find this lesson useful, please upvote and share it so that others can benefit as well. If you encounter any issues or have recommendations for future tutorials, please leave a comment and let me know.

Top comments (3)

Collapse
 
zahra_aghaee_8ac080f10e18 profile image
zahra aghaee

This is great, Thank you.

Collapse
 
tanabe_yutaka_18dc6e9adbc profile image
Tanabe Yutaka

Wonderful. What a comprehensive post!

Collapse
 
uraeustriforce profile image
Dave

This was extremely helpful. Thank you so much for posting this!