跳到内容

FoxTagger Snap:使用用户自定义标签映射地址

InterIIT黑客马拉松获奖者

12 分钟阅读
FoxTagger Snap:使用用户自定义标签映射地址

MetaMask Snaps 是让 MetaMask 成为全球最具扩展性钱包的发展路线图。作为开发者,你可以通过全新的方式将自己的功能和 API 带入 MetaMask。Web3 开发者是这一增长的核心,本系列旨在展示当今正在构建的创新 MetaMask Snaps。

FoxTagger Snap

Snap 代码库:https://github.com/shree675/FoxTagger

FoxTagger 是一款工具,通过将地址与用户自定义标签进行映射,帮助用户追踪支出情况,并通过提醒和展示使用分析来协助用户管理消费。

请带我们了解一下技术实现?

技术架构

我们的 MetaMask 扩展程序 FoxTagger 由两个主要组件构成:MetaMask Snaps 后端(即 snap)和 Gatsby.js 前端。前端是一个承载用户界面的简单 Web 应用,同时也作为我们 Snaps 应用的配套 DApp。

大部分功能在 snap 中实现,其余功能则在前端实现,以便用户通过我们的界面向他人发起付款请求和/或配置标签。其中,请求付款功能使用 XMTP 协议,而标签功能则使用 MetaMask Snaps API。

我们以 snap monorepo 作为起始模板,项目的完整实现可在此处查看。

Snaps 应用

在整个实现过程中,我们使用了 Snaps API 提供的以下主要功能:

  1. 持久化存储

  2. 通知

  3. 定时任务

  4. 交易洞察

我们的实现首先在 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.');
  }
};
复制

getPersistenceStorage 和 setPersistenceStorage 函数是最基础的函数,负责标签及其他信息的存储与检索,其定义如下方代码片段所示。

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],
  });
};
复制

针对所有功能的实现,我们为持久化存储设计了合适的数据结构。

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

用户可以拥有多个账户,每个账户都有其独立的标签、使用记录和用户自定义限额。

交易洞察

交易洞察功能在进行中的交易中显示关联标签、对应支出以及相关提醒(如有)。

在同一个 index.ts 文件中,我们添加了一个交易洞察处理程序,使我们能够拦截进行中的交易并与 MetaMask 钱包进行交互。

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

  return {
    insights,
  };
};
复制

getDetails 函数实现了计算支出和显示提醒的完整逻辑,该函数在 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,
  };
};
复制

在此,我们首先检查当前交易的 to 哈希(即 to 地址)是否存在于存储中,然后检索并计算使用百分比。接着,我们检查使用量是否即将达到或已经达到该标签的设定限额,并发送相应的提醒。

请注意,我们使用 ethers npm 包中的 BigNumber 和 FixedNumber 类来处理大数字的算术运算。

交易洞察功能现已完成,以下是最终效果:

定时任务

此处定义了三个定时任务:weeklySummary 用于每周生成摘要,checkLimits 用于检查用户是否超出任意标签的限额,updateAmount 用于更新用户新交易的使用信息。

这些定时任务在 packages\snap\snap.manifest.json 中以相应的频率进行配置,同时在 index.ts 文件中加入了定时任务处理程序。

这些功能在 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;
};
复制

在此,我们遍历存储并检查用户是否超出任意标签的限额。然后将 notified 重置为 false,以便 checkLimits 定时任务可以重新使用。我们返回一个布尔值,供定时任务处理程序在每周末为每个用户账户发送相应的通知消息。

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;
};
复制

在上述函数中,我们再次遍历所有未通知的用户标签并检查其使用情况。如果超出设定限额,则将 notified 设置为 true,确保该标签在下周之前不会被再次处理。我们向定时任务处理程序返回一条消息,说明超出限额的标签数量及用户的账户哈希。

接下来,我们完成 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;
};
复制

在此,我们使用 etherscan API 获取所有用户交易,并更新每个标签的使用详情。我们将获取的交易按时间倒序排列,并从 latestHash 交易开始扫描,以避免重复扫描。最后更新 latestHash。该定时任务的运行频率高于 checkLimits。

以下是上述实现的部分输出结果:

前端 DApp

标签功能

网站包含两个页面。第一个页面是登录页,用户可以在此登录、添加/删除标签、查看标签使用分布、设置限额,以及对标签和交易应用筛选器。所有这些功能均通过熟悉的 React.js 实现。交易数据从前述 etherscan API 获取,地址与对应标签相关联,这些信息来自已连接 Snaps 的持久化存储。

上述大部分功能的逻辑写在 packages\site\src\pages\GetTableData.jsx 中。snap 与前端的集成虽然复杂,但完成后逻辑清晰直观。

snap 方法通过在 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',
      },
    ],
  });
};
复制

我们还使用 react-chartjs-2 npm 包来展示用户支出的分析图表。

以下是网站的最终界面:

请求付款功能

这是一项独特功能,用户可以通过 XMTP 向另一位用户发送通知,请求对方转账 ETH。使用此功能时,双方都需要启用该 snap。此功能可在 http://localhost:8000/request 访问。

为此,我们使用 @xmtp/xmtp-js npm 包,分别在 packages\site\src\contexts\WalletContext.tsx 和 packages\site\src\contexts\XmtpContext.tsx 中创建 WalletContext 和 XmtpContext,同时创建相应的 hooks 和组件。大部分代码直接来源于文档中的示例。

我们在网站中新建了一个页面 packages\site\src\pages\request.tsx,用于承载该功能的用户界面。我们使用 useSendMessage hook 中的 sendMessage 函数发送消息。

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

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

并通过 XmtpContext 的 provider 状态显示接收到的消息。

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

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

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

参照上述文档中的示例后,我们最终实现了所需功能:

总结

至此,FoxTagger 全部完成——这是一个将我们的创意转化为 MetaMask Snaps 应用并配套 DApp 的完整项目。

如果继续推进,下一步计划是什么?

下一步将通过添加更多分析功能并引入机器学习模型来扩展标签功能,从而对如何降低用户支出进行预测和建议。此后,我们计划加入交易分摊功能,允许用户在多个账户之间分摊费用,作为请求付款功能的延伸。

此外,MetaMask Snap 的全新自定义 UI 功能可被用于在 snap 内提供更优质的用户体验。最后,该 snap 还可以扩展至 Goerli 测试网以外的其他链。

能介绍一下你和你的团队吗?

从构思到实现,我们的团队通力协作,共同产生创意、不断打磨,并克服重重挑战,最终完成了 FoxTagger Snaps。

Sachin Sahu 在 DApp 的前端开发和功能实现中发挥了关键作用。他不仅协助将 MetaMask Snaps 与平台集成,还参与了演示和文档工作,确保用户获得开始使用 Snap DApp 所需的全部信息。

Siddhartha G 在前端设计和实现方面的专业能力对平台开发至关重要。他创建了直观的前端组件和方法,用于数据的展示与修改,并协助将 MetaMask Snaps 与 DApp 集成。

Shreetesh M 负责后端实现,确保所有函数均暴露给前端。他实现了交易洞察和定时任务功能,帮助团队将后端与前端无缝集成。

Noble Saji Mathews 在通过链上消息传递构建交易框架方面做出了重要贡献。他还参与了标签和交易请求系统的构思过程,提升了平台的可用性和用户友好度。

Kranthi 以独特的视角提出了标签系统的创意,并探索了将去中心化通信融入其中的新方法。他的努力促成了"请求"功能的实现,彻底改变了用户与 Snap DApp 的交互方式。

Ansh Anand 在前端设计和构思方面的专业能力使他成为团队不可或缺的一员。他参与制作了演示视频,展示了平台的核心能力,吸引了更多用户。

你认为 MetaMask Snaps 为 Web3 领域带来了哪些机遇?

MetaMask 拥有超过 2100 万活跃用户,要满足所有人的需求确实很难。这正是 MetaMask Snaps 的用武之地——开发者社区现在拥有更大的自由度,可以根据用户需求对 MetaMask 进行定制和个性化配置,使与不同区块链或不同协议构建的各类 DApp 的交互比以往更加便捷。

MetaMask Snaps 的优势不胜枚举,包括:追踪长期支出、分析消费习惯、将每笔交易的一小部分捐给慈善机构、向客户请求付款、为 Netflix 订阅设置自动付款等等!借助 MetaMask Snaps,将 MetaMask 与各类 DeFi 协议、NFT 市场和社交媒体平台集成现在变得轻而易举。

但 Snaps 不仅仅适用于复杂的金融交易。想象一下和朋友出去吃午饭并用加密货币付款——有了 MetaMask Snaps,分摊账单和向朋友请求付款只需轻轻一点!这就是 MetaMask Snaps 的强大之处!

随着 Web3 领域持续演进,MetaMask Snaps 必将成为开发者和用户日益重要的工具。随着元宇宙潜力的逐步显现,Snaps 的应用场景真正可以说是无穷无尽。

对于有意尝试 MetaMask Snaps 的开发者,你有什么建议?

我们建议他们首先浏览 GitHub 上现有的 snaps。这将帮助他们深入理解 MetaMask Snaps 所提供功能的潜力,尽管这些功能在纸面上看起来很简单。

我们还建议他们使用现有的 monorepo 模板作为实现的起点,这样做有很多好处:代码库结构清晰,配备了代码检查和自动化操作;开发者可以在一个包中同时实现配套 Web 应用和 snap;此外,该代码库始终与官方文档保持同步。

最后,我们建议他们在社区频道上发布问题或疑惑,扩大自己在 MetaMask Snaps 领域的影响力。

使用 MetaMask Snaps 进行构建

开始使用 MetaMask Snaps:

  1. 查阅开发者文档

  2. 安装 MetaMask Flask

  3. 查看 MetaMask Snaps 指南

  4. 在 Twitter、GitHub 讨论区和 Discord 上与我们保持联系

请关注我们团队在你附近举办的下一场黑客松!Happy BUIDLing ⚒️

免责声明:MetaMask Snaps 通常由 Consensys Software 以外的第三方开发。使用第三方开发的 MetaMask Snaps 完全由您自行决定并承担风险,且您同意对因此类活动造成的任何损失或损害承担全部责任。Consensys 对第三方开发的 MetaMask Snaps 不作任何明示或暗示的保证,并对第三方开发的 Snaps 免除一切责任。使用区块链相关软件存在风险,使用 MetaMask Snaps 即表示您完全接受这些风险。

AI 翻译。可能包含错误。请务必核实信息。

评价此翻译

本文作者:

  • MetaMask

    MetaMask(前身为 Consensys Software Inc)是全球最大的自托管金融平台,为用户提供一站式服务,涵盖加密资产与传统资产的持有、消费、储蓄与增值。公司正在打造实现上述功能的消费级平台,将支付、储蓄、投资与数字资产整合于一体,带来无缝一体化的使用体验。MetaMask 脱胎于全球使用最广泛的自托管钱包,让用户直接掌控自己的资金与资产,覆盖约 190 个国家和地区。自 2016 年起,MetaMask 在以太坊的发展历程中扮演了举足轻重的奠基角色。如今,MetaMask 处于区块链经济的核心位置,致力于构建「开放货币」的操作系统,让每个人都能全面掌控自己的财务生活。

    阅读所有文章