MetaMask Snaps는 MetaMask를 세계에서 가장 확장 가능한 지갑으로 만들기 위한 로드맵입니다. 개발자라면 완전히 새로운 방식으로 자신만의 기능과 API를 MetaMask에 구현할 수 있습니다. Web3 개발자들은 이러한 성장의 핵심이며, 이 시리즈는 현재 개발 중인 혁신적인 MetaMask Snaps를 소개하는 것을 목표로 합니다.
FoxTagger Snap
Snap 저장소: https://github.com/shree675/FoxTagger
FoxTagger는 사용자가 정의한 태그와 주소를 매핑하여 사용자의 지출을 관리하고, 알림 및 사용 분석을 표시하는 도구입니다.
기술적 구현 방식을 설명해 주세요.
기술 설정
MetaMask 확장 프로그램인 FoxTagger는 MetaMask Snaps 백엔드(즉, snap)와 Gatsby.js 프론트엔드, 두 가지 주요 구성 요소로 이루어져 있습니다. 프론트엔드는 UI를 호스팅하는 간단한 웹 애플리케이션이면서, Snaps 애플리케이션의 컴패니언 DApp 역할도 합니다.
대부분의 기능은 snap에서 구현되지만, 나머지 기능은 사용자가 다른 사람에게 금액을 요청하거나 UI를 통해 태그를 설정할 수 있도록 프론트엔드에서 처리됩니다. 금액 요청 기능은 XMTP 프로토콜을 사용하고, 태깅 기능은 MetaMask Snaps API를 사용합니다.
시작 템플릿으로 snap monorepo를 사용했으며, 전체 구현 내용은 여기에서 확인할 수 있습니다.
Snaps 애플리케이션
전체 구현에서 Snaps API가 제공하는 다음과 같은 주요 기능을 활용합니다:
영구 저장소
알림
Cron 작업
트랜잭션 인사이트
구현은 먼저 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 클래스를 사용한다는 점에 주목하세요.
트랜잭션 인사이트 구현이 완료되었으며, 최종 결과는 다음과 같습니다:
Cron 작업
여기에는 세 가지 cron 작업이 정의되어 있습니다. weeklySummary는 매주 요약을 생성하고, checkLimits는 사용자가 태그 한도를 초과했는지 확인하며, updateAmount는 사용자가 수행한 새 트랜잭션의 사용 정보를 업데이트합니다.
이 cron 작업들은 packages\snap\snap.manifest.json에 적절한 실행 주기와 함께 명시되어 있습니다. 또한 index.ts 파일에 cron 작업 핸들러가 포함되어 있습니다.
이러한 기능들은 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;
}
newUsage[tag].notified = false;
newUsage[tag].used = '0';
}
}
if (hasTag) {
storage.usage = newUsage;
completeStorage[account] = storage;
await setPersistentStorage(completeStorage);
}
return exceeded;
};
여기서는 저장소를 순회하며 사용자가 태그 한도를 초과했는지 확인합니다. 그런 다음 notified를 false로 재설정하여 checkLimits cron 작업에서 재사용할 수 있도록 합니다. 각 사용자 계정에 대해 cron 작업 핸들러가 처리할 수 있도록 boolean 값을 반환하고, 주말에 적절한 알림 메시지를 전송합니다.
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로 설정합니다. 한도를 초과한 태그 수와 사용자 계정 해시를 cron 작업 핸들러에 메시지로 반환합니다.
다음으로 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;
}
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를 업데이트합니다. 이 cron 작업은 checkLimits보다 더 자주 실행됩니다.
위 구현의 몇 가지 출력 결과는 다음과 같습니다:
프론트엔드 DApp
태깅 기능
웹사이트는 두 개의 페이지로 구성되어 있습니다. 첫 번째 페이지는 랜딩 페이지로, 사용자가 로그인하고 태그를 추가/삭제하며, 태그 사용 분포를 확인하고, 한도를 설정하며, 태그와 트랜잭션에 필터를 적용할 수 있습니다. 이 모든 기능은 친숙한 React.js 기능을 통해 구현됩니다. 트랜잭션은 앞서 언급한 etherscan API에서 가져오며, 주소는 해당 태그와 연결됩니다. 이 정보는 연결된 Snaps의 영구 저장소에서 가져옵니다.
위 기능 대부분의 로직은 packages\site\src\pages\GetTableData.jsx에 작성되어 있습니다. snap과 프론트엔드의 통합은 복잡하지만, 완성되고 나면 직관적으로 이해할 수 있습니다.
snap 메서드는 packages\site\src\utils\snap.ts에 다음 함수들을 작성하여 프론트엔드에 노출됩니다:
export const getStorage = async () => {
return await window.ethereum.request({
method: 'wallet_invokeSnap',
params: [
defaultSnapOrigin,
{
method: 'getPersistentStorage',
},
],
});
};
export const setStorage = async (data: Record<string, unknown> | void) => {
await window.ethereum.request({
method: 'wallet_invokeSnap',
params: [
defaultSnapOrigin,
{
method: 'setPersistentStorage',
params: data,
},
],
});
};
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를 생성합니다. 또한 훅과 컴포넌트도 생성합니다. 이 코드의 대부분은 문서의 예제에서 직접 가져왔습니다.
이 기능의 UI를 수용하기 위해 웹사이트에 새 페이지 packages\site\src\pages\request.tsx를 생성합니다. useSendMessage 훅의 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 Testnet 네트워크 외의 다른 체인으로 확장할 수 있습니다.
본인과 팀에 대해 간략히 소개해 주시겠어요?
아이디어 구상부터 구현까지, 저희 팀은 협력하여 아이디어를 발굴하고 다듬으며 어려움을 극복하여 FoxTagger Snaps를 완성했습니다.
Sachin Sahu는 dApp의 프론트엔드와 기능 개발에 핵심적인 역할을 담당했습니다. MetaMask Snaps를 플랫폼에 통합하는 것을 도왔을 뿐만 아니라, 사용자가 Snap의 dApp을 시작하는 데 필요한 모든 정보를 갖출 수 있도록 데모와 문서 작업도 진행했습니다.
Siddhartha G의 프론트엔드 디자인 및 구현 전문성은 플랫폼 개발에 매우 중요했습니다. 그는 데이터를 표시하고 수정하기 위한 직관적인 프론트엔드 컴포넌트와 메서드를 만들고, MetaMask Snaps와 dApp의 통합을 지원했습니다.
Shreetesh M은 백엔드 구현을 담당하여 모든 함수가 프론트엔드에 노출되도록 했습니다. 그는 트랜잭션 인사이트와 cron 작업을 구현하여 팀이 백엔드와 프론트엔드를 원활하게 통합하는 데 기여했습니다.
Noble Saji Mathews의 기여는 온체인 메시징을 통한 트랜잭션 프레임워크 초안 작성에 결정적인 역할을 했습니다. 그는 플랫폼의 사용성과 편의성을 높이는 태깅 및 트랜잭션 요청 시스템의 아이디어 구상 과정에도 참여했습니다.
Kranthi는 태깅 시스템 아이디어를 제안하고 탈중앙화 통신을 통합하는 새로운 방법을 탐구하며 독창적인 시각을 제시했습니다. 그의 노력으로 사용자가 Snap의 dApp과 상호작용하는 방식을 혁신한 '요청' 기능이 구현되었습니다.
Ansh Anand의 프론트엔드 디자인 및 아이디어 구상 전문성은 그를 팀의 없어서는 안 될 구성원으로 만들었습니다. 그는 플랫폼의 기능을 선보이고 더 많은 사용자를 유치하는 데 도움이 된 데모 영상 제작을 담당했습니다.
MetaMask Snaps와 Web3 공간에서 이것이 열어주는 가능성에 대해 어떤 기회를 보시나요?
MetaMask의 월간 활성 사용자가 2,100만 명을 넘는 상황에서, 모든 사람의 요구를 충족하는 기능을 구현하기란 쉽지 않습니다. 바로 이 지점에서 MetaMask Snaps가 등장합니다. 개발자 커뮤니티는 이제 사용자 요구 사항에 맞게 MetaMask를 커스터마이징하고 개인화할 수 있는 더 많은 자유를 갖게 되었으며, 서로 다른 블록체인이나 프로토콜을 사용하는 다양한 dApp과의 상호작용이 그 어느 때보다 쉬워졌습니다.
MetaMask Snaps의 장점은 매우 다양합니다. 시간에 따른 지출 추적, 소비 분석, 각 트랜잭션의 소액을 자선단체에 기부, 클라이언트에게 결제 요청, Netflix 구독 자동결제 설정 등 훨씬 더 많은 기능을 활용할 수 있습니다! MetaMask Snaps를 통해 MetaMask를 다양한 DeFi 프로토콜, NFT 마켓플레이스, 소셜 미디어 플랫폼과 통합하는 것도 이제 매우 간편해졌습니다.
하지만 Snaps는 복잡한 금융 트랜잭션만을 위한 것이 아닙니다. 친구들과 점심을 먹고 암호화폐로 결제하는 상황을 상상해 보세요. MetaMask Snaps를 사용하면 비용을 나누고 친구들에게 결제를 요청하는 것이 단 한 번의 탭만큼 간단합니다! 이것이 바로 MetaMask Snaps의 힘입니다!
Web3 공간이 계속 발전함에 따라, MetaMask Snaps는 개발자와 사용자 모두에게 점점 더 중요한 도구가 될 것입니다. 메타버스의 가능성이 지평선 너머로 다가오는 지금, Snaps의 활용 사례는 진정으로 무한합니다.
MetaMask Snaps를 시도하려는 개발자들에게 조언을 해주신다면?
먼저 GitHub에 있는 기존 snap들을 살펴보시길 권장합니다. 기능들이 문서상으로는 단순해 보일 수 있지만, 이를 통해 MetaMask Snaps가 제공하는 기능의 잠재력을 이해하는 데 큰 도움이 될 것입니다.
또한 구현을 시작할 때 기존 monorepo 템플릿을 사용하시길 권장합니다. 이 템플릿에는 많은 장점이 있습니다. 저장소가 린팅 검사 및 액션과 함께 잘 구조화되어 있고, 개발자가 하나의 패키지에서 컴패니언 웹 앱과 snap을 모두 구현할 수 있습니다. 또한 저장소는 항상 공식 문서와 동기화되어 있습니다.
마지막으로, 커뮤니티 채널에 문제나 궁금한 점을 게시하고 MetaMask Snaps에 대한 노출을 늘리시길 권장합니다.
MetaMask Snaps로 개발하기
MetaMask Snaps를 시작하려면:
개발자 문서를 확인하세요
MetaMask Flask를 설치하세요
MetaMask Snaps 가이드를 참고하세요
Twitter, GitHub discussions, Discord에서 저희와 소통하세요
다음 해커톤에서 저희 팀을 만나보세요! 즐거운 BUIDLing ⚒️
면책 조항: MetaMask Snaps는 일반적으로 Consensys Software 이외의 제3자가 개발합니다. 제3자가 개발한 MetaMask Snaps의 사용은 전적으로 사용자 본인의 판단과 위험 부담 하에 이루어지며, 그러한 활동으로 인해 발생하는 모든 손실이나 피해에 대한 책임은 전적으로 사용자에게 있음에 동의하는 것으로 간주됩니다. Consensys는 제3자가 개발한 MetaMask Snaps에 대해 구두 또는 서면을 불문하고 어떠한 명시적 또는 묵시적 보증도 하지 않으며, 제3자가 개발한 Snaps에 대한 모든 책임을 부인합니다. 블록체인 관련 소프트웨어 사용에는 위험이 따르며, MetaMask Snaps를 사용함으로써 해당 위험을 전적으로 감수하는 것에 동의하게 됩니다.