Skip to main content

How to Audit ERC20, ERC721, and ERC1155 Token Activity using QuickNode SDK

Created on
Updated on
Nov 26, 2024

24 min read

Overview​

In the dynamic landscape of blockchain technology, auditing firms face the crucial task of efficiently tracking and analyzing transactions on blockchain networks. This need becomes particularly concerning during tax assessments or when addressing inquiries from financial regulatory bodies. Traditional methods of accessing blockchain data can be intricate and time-consuming. To address this, we have developed a comprehensive guide utilizing the QuickNode SDK with JavaScript, specifically tailored for EVM-compatible chains, including Ethereum, Polygon, and Avalanche. While the process becomes more efficient, the source of information is still the blockchain itself. The QuickNode SDK is just an interface that makes JSON-RPC calls to our endpoints. This guide simplifies the process of conducting in-depth audits on wallet addresses.

Our step-by-step guide is designed to streamline the way auditing firms access and interpret blockchain transactions. For auditing firms embarking on the journey of blockchain data analysis, whether for routine compliance checks or regulatory responses, this guide is an essential resource. QuickNode's commitment to enhancing blockchain accessibility is evident in our continuous support and custom solutions.

If you encounter any challenges following this guide or require personalized assistance for your blockchain data retrieval needs, QuickNode's team is at your service. Contact us for expert guidance and support by using the feedback form in the Conclusion section.

What You Will Do​

In this guide, you will fetch all ERC20, ERC721, and ERC1155 transaction activities associated with a wallet, including:


  • Transaction history
  • Fungible token transfer history (ERC20)
  • Non-fungible token (NFT) transfer history (ERC721 and ERC1155)
  • Internal transaction history

What You Will Need​

It's helpful to have the following before you start, but it doesn't matter if you don't. We will show the installation stages of the project and the stages of getting an endpoint on QuickNode.


DependencyVersion
node.js^16
@quicknode/sdk^1.1.4
fs-extra^11.1.1
cli-progress^3.12.0

Development​

Access to the Blockchain​

You'll need API endpoints to interact with the Ethereum network in order to query data from the blockchain. Create a free QuickNode account here, then log in and click the Create an endpoint button, then pick the chain and network based on your preferences.

In this guide, we will walk through how to get token activities from Avalanche C-chain. However, the code in this guide is applicable to all EVM compatible chains like Ethereum, Polygon, and Arbitrum. Whichever network you want to use, just use that HTTP Provider link after creating the endpoint. There is no additional change needed.

After creating your endpoint, copy the HTTP Provider link and keep it handy.

Project Configuration​

Before diving into the coding, it is time to learn more about the libraries that are used in this project and their functionalities.

  • @quicknode/sdk: The library that provides easy-to-use functions to fetch token metadata, balances, and transfer histories without manually interacting with the blockchain or smart contracts

  • fs-extra: The file system module for file operations like writing to a file

  • cli-progress: The CLI progress bar module for visual progress feedback in the console

Now, let's start building the project.

Create a project directory and change the directory into it:

mkdir tokenActivities
cd tokenActivities

Then, initialize an npm project with the default options:

npm init --y

To install these libraries, we will use the node.js package manager, npm.

npm install @quicknode/sdk cli-progress fs-extra

NOTE: After completing this step, insert "type": "module" into the package.json file just created. This enables the use of ES Module Syntax.

So, your package.json file should be similar to the one below.

{
"name": "tokenActivities",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@quicknode/sdk": "^1.1.4",
"cli-progress": "^3.12.0",
"fs-extra": "^11.1.1"
}
}

Now, we should have our libraries installed in our project directory.

Token Activities for ERC20, ERC721, and ERC1155​

Create a JavaScript file named index.js, open it with your favorite code editor (i.e., VS Code), and paste the following code into it. Replace the QUICKNODE_ENDPOINT placeholder, which is highlighted in the code snippet, with your endpoint's HTTP Provider link.

// Importing necessary modules and libraries
import { Core, viem } from "@quicknode/sdk"; // Importing Core and viem from the QuickNode SDK
import fs from "fs-extra"; // Importing the file system module for file operations
import * as cli from "cli-progress"; // Importing the CLI progress bar module for visual progress feedback in the console

// Creating a new instance of Core from the QuickNode SDK
const core = new Core({
endpointUrl: 'QUICKNODE_ENDPOINT', // The endpoint URL of your QuickNode. Replace "QUICKNODE_ENDPOINT" with your actual QuickNode endpoint URL.
})

// Function to get ERC20 token transfers for a specific address within a given block
async function getERC20TokenTransfers(address, blockNum) {
// STEP 1
}
// Function to get ERC721 token transfers for a specific address within a given block
async function getERC721TokenTransfers(address, blockNum) {
// STEP 2
}

// Function to get ERC1155 token transfers for a specific address within a given block
async function getERC1155TokenTransfers(address, blockNum) {
// STEP 3
}

// Function to parse transfer event logs into a more readable format
function parseTransferEvents(events) {
// STEP 4
}

// Function to get internal transactions for a specific transaction hash
async function getInternalTransactions(txHash) {
// STEP 5
}

// Function to get transactions for specific addresses within a block range and for given token types
async function getTransactionsForAddresses(
addresses,
fromBlock,
toBlock,
tokenTypes
) {
// STEP 6
}

// Function to check and validate the input variables: addresses, block numbers, and token types
function checkVariables(addresses, fromBlock, toBlock, tokenTypes) {
// STEP 7
}

// Main function to run the transaction fetching process
async function run(addresses, fromBlock, toBlock, tokenTypes) {
// STEP 8
}

To avoid any confusion, we will first talk about the logic of the functions. Then, we will fill in the functions step by step.

  • getERC20TokenTransfers - Fetching ERC20 Transfers: Fetching all ERC20 token transfers for a given address within a specific block.

  • getERC721TokenTransfers - Fetching ERC721 Transfers: Fetching all ERC721 token transfers for a given address within a specific block.

  • getERC1155TokenTransfers - Fetching ERC1155 Transfers: Fetching all ERC1155 token transfers for a given address within a specific block.

  • parseTransferEvents - Parsing Transfer Events: Parsing these transfer events to convert blockchain data into a more readable and interpretable format, enhancing the clarity of the audit results.

  • getInternalTransactions - Extracting Internal Transactions: Fetching internal transactions associated with a specific transaction hash, revealing the depth of interactions that occur within the blockchain.

  • getTransactionsForAddresses - Gathering Transactions for Addresses: The core functionality revolves around collating transactions for specified addresses across a range of blocks and token types, ensuring a wide-reaching and detailed audit. All functions that are mentioned above are performed in this function.

  • checkVariables - Validating Inputs: Prior to data extraction, a validation step confirms the integrity and format of input variables such as addresses, block numbers, and token types, ensuring the accuracy and reliability of the audit process.

  • run - Running the Audit Process: Finally, the main function is to run the entire transaction fetching operation. Checking variables, getting transactions, and writing all results to the file are performed in the run function.

Fetching ERC20 Transfers​

This function, getERC20TokenTransfers, is designed to retrieve ERC20 token transfer events from the blockchain for a specific address within a specified block. It separately queries transfers sent from and received by the address and then parses these event logs to extract detailed transfer information. The parseTransferEvents function (not shown here) would be responsible for interpreting the log data and converting it into a more user-friendly format.

Replace the getERC20TokenTransfers function with the following code. Check the comments for more detailed information.

// Function to get ERC20 token transfers for a specific address within a given block
async function getERC20TokenTransfers(address, blockNum) {
const transfers = []; // Array to store the transfers

// Convert the block number to hexadecimal format
const blockHex = viem.toHex(blockNum);

// Fetch logs of token transfers sent from the given address
const sentTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: { from: address },
strict: true,
});

// Parse the events of token transfers sent
let parsedEvents = parseTransferEvents(sentTransfers);

// Add the parsed sent transfers to the transfers array
transfers.push(...parsedEvents);

// Fetch logs of token transfers received by the given address
const receivedTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: { to: address },
strict: true,
});
// Parse the events of token transfers received
parsedEvents = parseTransferEvents(receivedTransfers);

// Add the parsed received transfers to the transfers array
transfers.push(...parsedEvents);

return transfers; // Return the combined list of sent and received transfers
}

Fetching ERC721 Transfers ​

In this function, getERC721TokenTransfers, the process is similar to fetching ERC20 token transfers but tailored for ERC721 (NFT) transfers. It queries the blockchain for Transfer events emitted by ERC721 contracts, indicating the transfer of an NFT. The function handles both transfers sent from and received by the specified address. It uses parseTransferEvents (not provided here) to interpret the log data into a more readable format, considering that ERC721 transfers include a tokenId to represent individual NFTs.

Replace the getERC721TokenTransfers function with the following code. Check the comments for more detailed information.

// Function to get ERC721 token transfers for a specific address within a given block
async function getERC721TokenTransfers(address, blockNum) {
const transfers = []; // Array to store the transfers

// Convert the block number to hexadecimal format
const blockHex = viem.toHex(blockNum);

// Fetch logs of ERC721 token transfers sent from the given address
const sentTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
),
args: { from: address },
strict: true,
});

// Parse the events of token transfers sent
let parsedEvents = parseTransferEvents(sentTransfers);

// Add the parsed sent transfers to the transfers array
transfers.push(...parsedEvents);

// Fetch logs of ERC721 token transfers received by the given address
const receivedTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
),
args: { to: address },
strict: true,
});

// Parse the events of token transfers received
parsedEvents = parseTransferEvents(receivedTransfers);

// Add the parsed received transfers to the transfers array
transfers.push(...parsedEvents);

return transfers; // Return the combined list of sent and received transfers
}

Fetching ERC1155 Transfers ​

In this function, getERC1155TokenTransfers, the process is tailored for ERC1155 token transfers, which can include both single and batch transfers. It queries the blockchain for TransferSingle and TransferBatch events emitted by ERC1155 contracts. The function handles transfers both sent from and received by the specified address. It uses parseTransferEvents (not provided here) to interpret the log data, considering the unique characteristics of ERC1155 transfers, including batch transfers involving multiple token IDs and quantities.

Replace the getERC1155TokenTransfers function with the following code. Check the comments for more detailed information.

// Function to get ERC1155 token transfers for a specific address within a given block
async function getERC1155TokenTransfers(address, blockNum) {
const transfers = []; // Array to store the transfers

// Convert the block number to hexadecimal format
const blockHex = viem.toHex(blockNum);

// Fetch logs of ERC1155 token transfers sent from the given address (for single transfers)
let sentTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event TransferSingle(address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value)"
),
args: { _from: address },
strict: true,
});

// Parse the events of single token transfers sent
let parsedEvents = parseTransferEvents(sentTransfers);
transfers.push(...parsedEvents);

// Fetch logs of ERC1155 token transfers sent from the given address (for batch transfers)
sentTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event TransferBatch(address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values)"
),
args: { _from: address },
strict: true,
});

// Parse the events of batch token transfers sent
parsedEvents = parseTransferEvents(sentTransfers);
transfers.push(...parsedEvents);

// Fetch logs of ERC1155 token transfers received by the given address (for single transfers)
let receivedTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event TransferSingle(address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value)"
),
args: { _to: address },
strict: true,
});

// Parse the events of single token transfers received
parsedEvents = parseTransferEvents(receivedTransfers);
transfers.push(...parsedEvents);

// Fetch logs of ERC1155 token transfers received by the given address (for batch transfers)
receivedTransfers = await core.client.getLogs({
fromBlock: blockHex,
toBlock: blockHex,
event: viem.parseAbiItem(
"event TransferBatch(address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values)"
),
args: { _to: address },
strict: true,
});

// Parse the events of batch token transfers received
parsedEvents = parseTransferEvents(receivedTransfers);
transfers.push(...parsedEvents);

return transfers; // Return the combined list of sent and received transfers
}

Parsing Transfer Events​

This function takes an array of event logs (events) and returns a new array where each event log is transformed into an object with a more readable format. Each object contains detailed information about the event, such as the contract address, value transferred, topics, transaction details, and more. The viem.fromHex function is used to convert hexadecimal values to a number, making them easier to interpret. This function is essential for understanding the data contained within blockchain event logs.

Replace the parseTransferEvents function with the following code. Check the comments for more detailed information.

// Function to parse transfer event logs into a more readable format
function parseTransferEvents(events) {
// Mapping each event to a formatted object
return events.map((event) => ({
contractAddress: event.address, // The address of the contract that emitted the event
value: event.data === "0x" ? "0x" : viem.fromHex(event.data, "number"), // The value transferred, converted from hex to number if not zero
topics: event.topics.map((topic) => topic), // The indexed event parameters
data: event.data, // The data field of the event log
args: event.args, // The arguments of the log (decoded parameters)
blockNumber: event.blockNumber, // The block number in which the event was recorded
logIndex: event.logIndex, // The index of the log inside the block
transactionIndex: event.transactionIndex, // The index of the transaction in the block
transactionHash: event.transactionHash, // The hash of the transaction
blockHash: event.blockHash, // The hash of the block containing the transaction
removed: event.removed, // A flag indicating if the log was removed due to a chain reorganization
}));
}

Extracting Internal Transactions​

In this function, getInternalTransactions, a request is made to the endpoint to trace a specific transaction using its hash (txHash). The trace is obtained using the debug_traceTransaction method, which provides detailed information about transaction execution, including internal transactions. These internal transactions are extracted from the call trace and returned by the function. Error handling is included to catch and log any issues that occur during the trace request, returning an empty array if an error is encountered.

Replace the getInternalTransactions function with the following code. Check the comments for more detailed information.

// Function to get internal transactions for a specific transaction hash
async function getInternalTransactions(txHash) {
try {
// Requesting a trace of the transaction using the debug_traceTransaction method
const traceResponse = await core.client.request({
method: "debug_traceTransaction",
params: [txHash, { tracer: "callTracer" }], // Using a call tracer for detailed transaction execution
});

const internalTxs = [];

// Check if the traceResponse object has the 'calls' property
if (Object.prototype.hasOwnProperty.call(traceResponse, "calls")) {
const result = traceResponse.calls; // Extracting the call trace from the response

// Add the call trace results to the internal transactions array
// The structure of the call trace determines how internal transactions are extracted
internalTxs.push(...result);
}

return internalTxs; // Return the parsed internal transactions
} catch (error) {
// Log and handle any errors that occur during the request
console.error("An error occurred:", error);
return []; // Return an empty array in case of an error
}
}

Gathering Transactions for Addresses​

This function getTransactionsForAddresses processes transactions within a specific block range for given addresses and token types. It compiles detailed information about each transaction, including token transfers for ERC20, ERC721, and ERC1155 tokens, as well as any internal transactions. The use of a progress bar provides a visual indication of the processing progress.

Replace the getTransactionsForAddresses function with the following code. Check the comments for more detailed information.

// Function to get transactions for specific addresses within a block range and for given token types
async function getTransactionsForAddresses(
addresses,
fromBlock,
toBlock,
tokenTypes
) {
// Initialize a progress bar
const bar1 = new cli.SingleBar({}, cli.Presets.shades_classic);

// Start the progress bar
bar1.start(toBlock - fromBlock, 0);

const transactions = []; // Array to store the transactions

// Loop through each block in the specified range
for (let blockNum = fromBlock; blockNum <= toBlock; blockNum++) {
// Stop the progress bar if it's the last block, else increment
blockNum === toBlock ? bar1.stop() : bar1.increment();

// Fetch the block and its transactions
const block = await core.client.getBlock({
blockNumber: blockNum,
includeTransactions: true,
});

// Process each transaction in the block
for (const tx of block.transactions) {
// Check if the transaction involves any of the specified addresses
if (
(tx.from && addresses.includes(viem.getAddress(tx.from))) ||
(tx.to && addresses.includes(viem.getAddress(tx.to)))
) {
// Initialize transaction details object
const txDetails = {
block: blockNum,
hash: tx.hash,
from: viem.getAddress(tx.from),
to: viem.getAddress(tx.to),
value: tx.value,
gas: tx.gas,
gasPrice: tx.gasPrice,
input: tx.input,
internalTransactions: [],
};

let typeTransfers = []; // Array to store token transfers

// Check if the sender address is one of the specified addresses
const isSender = addresses.includes(viem.getAddress(tx.from));

// Process each token type
for (const tokenType of tokenTypes) {
// Fetch token transfers based on the token type and whether the address is a sender or receiver
if (isSender) {
// Handle token transfers based on the specific token type for sender address
switch (tokenType) {
case "ERC20":
typeTransfers = await getERC20TokenTransfers(tx.from, blockNum);
break;
case "ERC721":
typeTransfers = await getERC721TokenTransfers(
tx.from,
blockNum
);
break;
case "ERC1155":
typeTransfers = await getERC1155TokenTransfers(
tx.from,
blockNum
);
break;
default:
throw new Error("No supported token type.");
}
} else {
// Handle token transfers based on the specific token type for receiver address
switch (tokenType) {
case "ERC20":
typeTransfers = await getERC20TokenTransfers(tx.to, blockNum);
break;
case "ERC721":
typeTransfers = await getERC721TokenTransfers(tx.to, blockNum);
break;
case "ERC1155":
typeTransfers = await getERC1155TokenTransfers(tx.to, blockNum);
break;
default:
throw new Error("No supported token type.");
}
}

// Add the fetched token transfers to the transaction details
if (typeTransfers.length) {
// If the token type does not already exist in txDetails, initialize it

if (!Object.prototype.hasOwnProperty.call(txDetails, tokenType)) {
txDetails[tokenType] = { tokenTransfers: [] };
}
// Add the transfers to the corresponding token type in txDetails
txDetails[tokenType].tokenTransfers.push(...typeTransfers);
}
}

// Fetch and add internal transactions if applicable
const bytecode = await core.client.getBytecode({
address: tx.to,
});

if (tx.to && bytecode !== "0x") {
txDetails.internalTransactions.push(
...(await getInternalTransactions(tx.hash))
);
}
// Add the detailed transaction to the transactions array
transactions.push(txDetails);
}
}
}
return transactions; // Return the collected transactions
}

Validating Inputs​

In this function:

  • Each address in the addresses array is validated to ensure it is a proper EVM compatible address.
  • It checks whether fromBlock and toBlock are integers and whether fromBlock is not greater than toBlock to ensure a valid block range.
  • The function verifies that each token type in the tokenTypes array is one of the specified valid token types ("ERC20", "ERC721", "ERC1155").
  • If any of these conditions are not met, an error is thrown with a descriptive message.

This validation ensures that the inputs to a function or process are correctly formatted and logically consistent before further processing or querying the blockchain.

Replace the checkVariables function with the following code. Check the comments for more detailed information.

// Function to check and validate the input variables: addresses, block numbers, and token types
function checkVariables(addresses, fromBlock, toBlock, tokenTypes) {
// Iterate through each address and check if it's a valid EVM-compatible address
addresses.forEach((address) => {
if (!viem.isAddress(address)) {
throw new Error(
`The address (${address}) is not EVM-compatible. Please check the addresses.`
);
}
});

// Check if 'fromBlock' and 'toBlock' are integers
if (!Number.isInteger(fromBlock) || !Number.isInteger(toBlock)) {
throw new Error("Block numbers must be an integer.");
}

// Check if 'fromBlock' is not greater than 'toBlock'
if (fromBlock > toBlock) {
throw new Error("Last block must be greater than first block.");
}

// Define valid token types
const validTokenTypes = ["ERC20", "ERC721", "ERC1155"];

// Check if all elements in 'tokenTypes' are valid token types
if (!tokenTypes.every((tokenType) => validTokenTypes.includes(tokenType))) {
throw new Error(
`Invalid token type: ${tokenTypes}. Must be one of ${validTokenTypes.join(
", "
)}.`
);
}
}

Running the Audit Process​

In this run function:


  • It starts by validating the input parameters using the checkVariables function.
  • Addresses are converted to a checksummed format for consistency and EVM compatibility.
  • It then fetches transactions related to the given addresses, block range, and token types by calling getTransactionsForAddresses.
  • The function includes a custom replacer for JSON.stringify to handle big integers correctly.
  • The fetched transactions are saved to a JSON file, and the function confirms the successful operation by logging to the console.

Replace the run function with the following code. Check the comments for more detailed information.

// Main function to run the transaction fetching process
async function run(addresses, fromBlock, toBlock, tokenTypes) {
try {
// Check if the input variables are valid
checkVariables(addresses, fromBlock, toBlock, tokenTypes);

// Convert all addresses to checksummed format for EVM compatibility
const checksummedAddresses = addresses.map((address) =>
viem.getAddress(address)
);

// Fetch transactions for the given addresses, block range, and token types
const transactions = await getTransactionsForAddresses(
checksummedAddresses,
fromBlock,
toBlock,
tokenTypes
);

// Define a replacer function for JSON.stringify to handle big integers
const replacer = (key, value) =>
typeof value === "bigint" ? Number(value) : value;

// Path for the output file
const outputFilePath = "wallet_audit_data.json";

// Convert the transactions object to a JSON string with indentation for readability
const stringified = JSON.stringify(transactions, replacer, 4);

// Write the JSON string to the specified file
fs.writeFileSync(outputFilePath, stringified);

console.log("Data has been saved to " + outputFilePath);
} catch (error) {
console.error("An error occurred:", error);
}
}

Usage Example​

After all function definition, the only thing left is calling the run function along with the wallet address for the audit, the block range of interest, and token standard types.

Add the code below to the end of the file. We select a random address which has a transaction that includes both ERC20 and ERC721 transfers on Avalanche C-chain.

// Usage example
// run(["address1", "address2"], fromBlock, toBlock, ["tokenStandard1", "tokenStandard2"]);

run(['0xe2233D97f30745fa5f15761B81B281BE5959dB5C'], 38063215, 38063220, [
'ERC20',
'ERC721',
])

Now, save the index.js file, and run the command below in your terminal.

node index.js

After execution, you will see a console output similar to the one below and find a wallet_audit_data.json file in the same folder directory with the entire activity trail of the wallet, including transactions, token transfers, and internal transactions.

> node indexNfts.js
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 100% | ETA: 0s | 5/5
Data has been saved to wallet_audit_data.json

Checking Results​

Here are the details of the transaction that is found in the program (on the left) and the view of the result file (on the right). It seems that our program successfully checked and found the transactions.

If you want to reference our code in its entirety, check out our GitHub page here.

Conclusion​

Congratulations! You are now able to perform a thorough audit on any wallet address on any EVM compatible chains for ERC20, ERC721, and ERC1155 token transfers! As we mentioned before, if you want to use a different network, simply change the endpoint.

To learn more about how QuickNode is helping auditing firms to pull this type of data from blockchains in a way that guarantees completeness and accuracy of the data, please feel free to reach out to us by using the feedback form below.

We ❀️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide