FoxTagger Snap: Mapping addresses with user-defined tags

An InterIIT Hackathon winner

by MetaMaskMay 10, 2023
Snaps Spotlights Feature Images

MetaMask Snaps is the roadmap to making MetaMask the most extensible wallet in the world. As a developer, you can bring your features and APIs to MetaMask in totally new ways. Web3 developers are the core of this growth and this series aims to showcase the novel MetaMask Snaps being built today.

FoxTagger Snap


Snap Repo: https://github.com/shree675/FoxTagger

FoxTagger is a tool that facilitates the mapping of addresses with user-defined tags to help users keep their expenditures in check by alerting and displaying usage analytics.

Walk us through the technical implementation?

The Technical Setup


Our MetaMask extension, FoxTagger, consists of two main components, a MetaMask Snaps backend (a.k.a. the snap) and a Gatsby.js frontend. While the frontend is a simple web application that hosts the UI, it also acts as a companion DApp for our Snaps application.

Most of the functionality is achieved in the snap, but the remaining is in the frontend to facilitate the users to request amount from others and/or configure their tags through our UI. While the request amount feature uses the XMTP protocol, the tagging feature uses the MetaMask Snaps API.

We have used the snap monorepo as the template to begin with. The full implementation of our project can be found here.

The Snaps Application


In our entire implementation, we make use of the following main features provided by the Snaps API:

  1. Persistence storage
  2. Notifications
  3. Cron jobs
  4. Transaction insights

Our implementation begins by first exposing some functions to the frontend in packages\snap\src\index.ts.

export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => {
  switch (request.method) {
    case 'getPersistentStorage':
      return await getPersistentStorage();
    case 'setPersistentStorage':
      await setPersistentStorage(
        request.params as void | Record<string, unknown>,
      );
      return null;
    case 'clearPersistentStorage':
      await clearPersistentStorage();
      return null;

    default:
      throw new Error('Method not found.');
  }
};

The functions getPersistenceStorage and setPersistenceStorage are very fundamental as they facilitate the storage and retrieval of tags and other information and are defined as shown in the below code snippet.

export const getPersistentStorage = async () => {
  return await wallet.request({
    method: 'snap_manageState',
    params: ['get'],
  });
};

export const clearPersistentStorage = async () => {
  await wallet.request({
    method: 'snap_manageState',
    params: ['clear'],
  });
};

export const setPersistentStorage = async (
  data: Record<string, unknown> | void,
) => {
  await wallet.request({
    method: 'snap_manageState',
    params: ['update', data],
  });
};

For all the feature implementations, we have created a suitable datastructure for our persistence storage.

{
    "from_account0": {
      mainMapping: {
        "to_account0": ["tag0","tag1"],
        ...
      },
      usage: {
        "tag0": {
          limit: "100000000000",
          used: "800000",
          notified: false
        },
        ...
      },
      latestHash: "transaction_hash0"
    },
    ...
  }

A user can have multiple accounts and each account has its own tags, usage and user-defined limits.

Transaction Insights


The transaction insights feature displays the associated tags, their corresponding expenditures and alerts, if any, in an ongoing transaction.

In the same index.ts file, we add a transaction insights handler that allows us to intercept an ongoing transaction and interact with the MetaMask wallet.

export const onTransaction: OnTransactionHandler = async ({ transaction }) => {
  const insights = await getDetails(transaction);

  return {
    insights,
  };
};

The function getDetails implements the entire logic for calculating the expenditure and showing alerts. This is implemented in packages\snap\src\transaction.ts.

export const getDetails = async (transaction: Record<string, unknown>) => {
  const toAddress = (transaction.to as string).toLowerCase();
  const account = (transaction.from as string).toLowerCase();
  const completeStorage = (await getPersistentStorage()) as any;

  if (!completeStorage?.[account]) {
    throw new Error('Storage initialization failed.');
  }

  const storage = completeStorage[account];
  if (!storage.mainMapping || !storage.usage) {
    throw new Error('Data corrput. Please re-install the snap.');
  }

  const tagList = storage.mainMapping[toAddress];
  if (!tagList?.length) {
    return {
      Tag: NO_TAG_MESSAGE,
    };
  }

  let tags = '';
  let alerts = '';
  let usageMsg = '';

  for (const tag of tagList) {
    if (tags === '') {
      tags += `${tag}`;
    } else {
      tags += `, ${tag}`;
    }
    let { used } = storage.usage[tag];
    let { limit } = storage.usage[tag];
    const fixedUsed = FixedNumber.from(used);
    const fixedLimit = FixedNumber.from(limit);
    const usedPercent = (
      Number(fixedUsed.divUnsafe(fixedLimit).toString()) * 100
    ).toFixed(2);
    used = BigNumber.from(used);
    limit = BigNumber.from(limit);

    const amount = BigNumber.from(transaction.value as string);
    const gas = BigNumber.from(transaction.gas as string);
    const total = amount.add(gas);

    if (usageMsg === '') {
      usageMsg += `${tag}: ${usedPercent}%`;
    } else {
      usageMsg += ` | ${tag}: ${usedPercent}%`;
    }

    if (!limit.eq('0')) {
      if (used.gt(limit)) {
        alerts += `${EXCEEDED_MESSAGE + toEth(limit)} for the tag ${tag}. `;
      } else if (used.add(total).gte(limit)) {
        alerts += `${WILL_EXCEED_MESSAGE + toEth(limit)} for the tag ${tag}. `;
      }
    }
  }

  if (alerts === '') {
    return {
      Tag: tags,
      Usage: usageMsg + FOOTER_NOTE,
    };
  }

  return {
    Tag: tags,
    Usage: usageMsg + FOOTER_NOTE,
    Alerts: alerts,
  };
};

In this, we are first checking if the current transaction’s to hash (or to address) is present in the storage. Then we retrieve and calculate the usage percentage. We then check if the usage is about to reach, or has already reached the set limit for that tag and we send an appropriate alert.

Notice that we use BigNumber and FixedNumber classes available in the ethers npm package to handle arithmetic operations on large numbers.

Transaction insights is now complete and this is the final result:

Screenshot 2023-05-10 at 1.22.44 PM

Cron Jobs


Defined here are three cron jobs, weeklySummary for generating the summary every week, checkLimits to check if the user has exceeded limits on any of his/her tags and updateAmount that updates the usage information of new transactions performed by the user.

These cron jobs are mentioned in packages\snap\snap.manifest.json, with their appropriate frequency. Additionally, a cron job handler is incorporated in index.ts file.

These functionalities are defined in packages\snap\src\cron.ts.

export const getSummary = async (account: string, completeStorage: any) => {
  const storage = completeStorage[account];

  if (!storage.usage) {
    return null;
  }

  const { usage } = storage;
  const newUsage: any = {};
  let exceeded = false;
  let hasTag = false;

  for (const tag in usage) {
    if (Object.prototype.hasOwnProperty.call(usage, tag)) {
      hasTag = true;
      const { used } = usage[tag];
      const { limit } = usage[tag];

      newUsage[tag] = usage[tag];

      if (
        BigNumber.from(used).gt(BigNumber.from(limit)) &&
        BigNumber.from(limit).gt('0')
      ) {
        exceeded = true;
      }

      // reset usage information for the next week
      newUsage[tag].notified = false;
      newUsage[tag].used = '0';
    }
  }

  if (hasTag) {
    storage.usage = newUsage;
    completeStorage[account] = storage;
    await setPersistentStorage(completeStorage);
  }

  return exceeded;
};

Here, we iterate through the storage and check if the user has crossed any limits on any of the tags. We then reset notified to false so that it can be reused by checkLimits cron job. We return a boolean value for the cron job handler to pick it up for each user account and send an appropriate notification message at the end of the week.

export const checkLimits = async (account: string, completeStorage: any) => {
  const storage = completeStorage[account];
  if (!storage.usage) {
    return null;
  }

  const { usage } = storage;
  const tags: string[] = [];
  const newUsage: any = {};

  for (const tag in usage) {
    if (Object.prototype.hasOwnProperty.call(usage, tag)) {
      const { used } = usage[tag];
      const { limit } = usage[tag];
      const { notified } = usage[tag];

      newUsage[tag] = usage[tag];
      if (
        !notified &&
        BigNumber.from(used).gt(BigNumber.from(limit)) &&
        BigNumber.from(limit).gt(BigNumber.from('0'))
      ) {
        tags.push(tag);
        newUsage[tag].notified = true;
      }
    }
  }

  if (tags.length > 0) {
    storage.usage = newUsage;
    completeStorage[account] = storage;
    await setPersistentStorage(completeStorage);

    const message = `${LIMIT_ALERT_HEADER + tags.length} tags on ${compact(
      account,
    )}`;
    return message;
  }

  return null;
};

In the above function, we once again iterate through all non-notified user tags and check their usage. If it has exceeded the set limit, we set notified to true so that this tag is not picked up again until the next week. We return a message to the cron job handler stating the number of tags that crossed the limit and the user’s account hash.

Next, we complete the implementation for updateAmount.

export const updateAmount = async (account: string, completeStorage: any) => {
  const response = await fetch(
    `https://api-goerli.etherscan.io/api?module=account&action=txlist&address=${account}&startblock=0&endblock=9999999999&sort=asc&apikey=${process.env.REACT_API_KEY}`,
  );
  const result = await response.json();

  if (!result.result) {
    return null;
  }

  let transactions = result.result;
  if (transactions.length === 0) {
    return null;
  }

  // sort in descending order
  transactions = transactions.sort(
    (a: any, b: any) => b.timeStamp - a.timeStamp,
  );

  const { latestHash } = completeStorage[account];
  const { prevHash } = completeStorage[account];
  if (transactions[0].hash.toLowerCase() === latestHash) {
    return null;
  }

  for (const transaction of transactions) {
    if (transaction.hash.toLowerCase() === latestHash) {
      break;
    }

    if (transaction.to !== account) {
      const toAddress = (transaction.to as string).toLowerCase();
      const tagList = completeStorage[account].mainMapping[toAddress];

      if (tagList !== null && tagList !== undefined) {
        for (const tag of tagList) {
          const gas = BigNumber.from(transaction.gasPrice).mul(
            BigNumber.from(transaction.gasUsed),
          );
          const value = BigNumber.from(transaction.value);
          const total = gas.add(value);
          if (prevHash !== latestHash) {
            completeStorage[account].usage[tag].used = BigNumber.from(
              completeStorage[account].usage[tag].used,
            )
              .add(total)
              .toString();
          } else {
            completeStorage[account].usage[tag].used = BigNumber.from('0')
              .add(total)
              .toString();
          }
        }
      }
    }
  }

  completeStorage[account].prevHash = latestHash;
  completeStorage[account].latestHash = transactions[0].hash.toLowerCase();

  return completeStorage;
};

Here, we use the etherscan API to fetch all the user transactions and update the usage details for each tag. We sort the obtained transactions in decreasing order of time and scan them from latestHash transaction to avoid rescanning of transactions. We finally update latestHash. This cron job runs more frequently than checkLimits.

Here are a few outputs of the above implementations:

Screenshot 2023-05-10 at 1.26.54 PM

The Frontend DApp


Tagging Feature

The website has two pages. The first page is the landing page where the user can login, add/remove tags, check his/her tag usage distribution, set limits and apply filters on the tags and transactions. All of this is achieved through the familiar React.js functionality. The transactions are fetched from the previously mentioned etherscan API and the addresses are associated with their corresponding tags. This information comes from the persistence storage of the connected Snaps.

The logic for most of the above features is written in packages\site\src\pages\GetTableData.jsx. The integration of the snap with the frontend is sophisticated, yet intuitive once finished.

The snap methods are exposed to the frontend by writing the following functions in packages\site\src\utils\snap.ts:

/**
 * Get the persisted data from the snap.
 *
 * @returns The persisted data, if any, 'null' otherwise.
 */
export const getStorage = async () => {
  return await window.ethereum.request({
    method: 'wallet_invokeSnap',
    params: [
      defaultSnapOrigin,
      {
        method: 'getPersistentStorage',
      },
    ],
  });
};

/**
 * Update the persistent storage in the snap.
 *
 * @param data - The complete data to be stored.
 */
export const setStorage = async (data: Record<string, unknown> | void) => {
  await window.ethereum.request({
    method: 'wallet_invokeSnap',
    params: [
      defaultSnapOrigin,
      {
        method: 'setPersistentStorage',
        params: data,
      },
    ],
  });
};

/**
 * Clear the persistent storage in the snap.
 */
export const clearStorage = async () => {
  return await window.ethereum.request({
    method: 'wallet_invokeSnap',
    params: [
      defaultSnapOrigin,
      {
        method: 'clearPersistentStorage',
      },
    ],
  });
};

We also use the react-chartjs-2 npm package to display analytics of the user’s expenditure.

This is the final look of the website:

Screenshot 2023-05-10 at 1.28.50 PM

Request Amount Feature


This is a unique feature in which a user can send a notification through XMTP to another user requesting for ETH. For this, both the users need to have this snap enabled. This feature is made available at http://localhost:8000/request.

For this, we use the @xmtp/xmtp-js npm package to create WalletContext and XmtpContext in packages\site\src\contexts\WalletContext.tsx and packages\site\src\contexts\XmtpContext.tsx respectively. We also create hooks and components. Most of this code directly comes from an example in the documentation.

We create a new page, packages\site\src\pages\request.tsx in the website to accommodate the UI for this feature. We send messages using the sendMessage function from the useSendMessage hook.

import useSendMessage from '../hooks/useSendMessage';

const sendNewMessage = () => {
    const payload = {
      id: Date.now(),
      message: msgTxt,
    };
    sendMessage(JSON.stringify(payload));
    setMsgTxt('');
};

And we display the received messages using the provider state of XmtpContext.

import { XmtpContext } from '../contexts/XmtpContext';

const Home = () => {
    const [providerState] = useContext(XmtpContext);
    const { convoMessages, client } = providerState;

    return (
        <>
            <ConversationList
                convoMessages={convoMessages}
                setSelectedConvo={setSelectedConvo}
            />
        </>
    )
};

After following the example in the above mentioned documentation, we finally attain the required functionality:

Screenshot 2023-05-10 at 1.31.53 PM

Conclusion


This completes FoxTagger, the entire project with our idea manifested into a MetaMask Snaps application, coupled with a DApp.

What are the next steps if you would implement this?

The next step would be to extend the tagging functionality by adding more analytics and using a Machine Learning model to make predictions and suggestions on how to minimize the user’s expenditure. Then we can aim to incorporate a transaction split feature where the user can split a fee among multiple accounts, adding on top of the request amount feature.

Additionally, MetaMask Snap’s new custom UI functionality could be leveraged to provide a more pleasing user experience inside the snap. Finally, the snap can be extended to chains other than the Goerli Testnet network.

Can you tell us a little bit about yourself and your team?

Screenshot 2023-05-10 at 1.17.17 PM

From ideation to implementation, our team collaborated to generate ideas, refine them, and overcome challenges to come up with our FoxTagger Snaps.

Sachin Sahu played a pivotal role in developing the frontend and functionalities of the dApp. Not only did he help integrate MetaMask Snaps with the platform, but he also worked on the demo and documentation to ensure that users have all the information they need to start using the Snap's dApp.

Siddhartha G's expertise in frontend design and implementation was crucial to the development of the platform. He created intuitive frontend components and methods to present and modify data and assisted in integrating MetaMask Snaps with the dApp.

Shreetesh M took charge of the backend implementation and ensured that all functions were exposed to the frontend. He implemented transaction insights and cron jobs, which helped the team integrate the backend seamlessly with the frontend.

Noble Saji Mathews's contributions were instrumental in drafting the framework for transactions through on-chain messaging. He was also part of the ideation process for the tagging and transaction request system, which enhances the platform's usability and user-friendliness.

Kranthi brought his unique perspective to the table by proposing the idea of the tagging system and exploring novel methods to incorporate decentralized communication. His hard work resulted in the implementation of the 'request' feature, which has revolutionized the way users interact with the Snap's dApp.

Ansh Anand's expertise in frontend design and ideation process made him an invaluable member of the team. He worked on the demo video, which showcased the platform's capabilities and helped attract more users.

What opportunities do you see with MetaMask Snaps and the possibilities it unlocks for the Web3 space?

With more than 21 million active users on MetaMask, it’s kind of hard to achieve functionality that fits everybody’s needs. That's where MetaMask Snaps come in - the developer community now has more freedom to customize and personalize MetaMask based on user requirements, making it easier than ever to interact with different dApps that may be built on different blockchains or use different protocols.

The benefits of MetaMask Snaps are numerous, including the ability to track expenditures over time, analyze spending, donate a small percent of each transaction to charity, request payment from clients, set up autopay for Netflix subscriptions, and much more! Integrating MetaMask with various DeFi protocols, NFT marketplaces, and social media platforms is now a breeze with MetaMask Snaps.

But Snaps aren't just for complex financial transactions. Imagine going out to lunch with friends and paying with cryptocurrency - with MetaMask Snaps, splitting the bill and requesting payment from your friends is as simple as a single tap! That’s the power of MetaMask Snaps!

As the Web3 space continues to evolve, MetaMask Snaps is sure to become an increasingly important tool for developers and users alike. With the potential of the metaverse on the horizon, the use cases of Snaps are truly endless.

Any advice you would like to share with developers keen to try MetaMask Snaps?

We would advise them to first go through the existing snaps on github. This will help them a lot in understanding the potential of the features provided by MetaMask Snaps, although the features look simple on paper.

We would also suggest they use the existing monorepo template to start with their implementation. It has many benefits. The repo is structured very well along with linting checks and actions, developers can implement both a companion web app and a snap in one package. Moreover, the repo is always on par with the official documentation.

Finally, we recommend them to post their issues or doubts on community channels and help increase their exposure to MetaMask’s Snaps.

Building with MetaMask Snaps


To get started with MetaMask Snaps:

  1. Checkout the developer docs
  2. Install MetaMask Flask
  3. Check out a MetaMask Snaps guide
  4. Stay connected with us on Twitter, GitHub discussions, and Discord

Keep an eye out for our team at the next hackathon in an area near you! Happy BUIDLing ⚒️

Disclaimer: MetaMask Snaps are generally developed by third parties other the ConsenSys Software. Use of third-party-developed MetaMask Snaps is done at your own discretion and risk and with agreement that you will solely be responsible for any loss or damage that results from such activities. ConsenSys makes no express or implied warranty, whether oral or written, regarding any third-party-developed MetaMask Snaps and disclaims all liability for third-party developed Snaps. Use of blockchain-related software carries risks, and you assume them in full when using MetaMask Snaps.

Receive our Newsletter