The Intro
To exchange crypto tokens from one token to another, we will use bridges where bridge uses its own liquidity pools for supported tokens and it takes care of transferring/exchanging tokens. In order to make that happen, users should approve the bridge contracts on their wallets for each token. Approved means users are allowing the bridge smart contract to move their tokens and exchange them from one token to another or the same token from one chain to another.
The Problem
Live Updates. As its a blockchain transaction, the result won’t reflect immediately and frontend ends up with showing previous state until a page refresh/reload. Updating the UI in realtime would be a nice UX for most of the applications.
The Decentralised Solution
Idea: Polling -> updating state -> re-rendering
With ideal polling you can synchronise within the device but what if I say there is a solution where we sync across devices or may be across different nodes based on your use case. Waku’s lightpush and filter protocols are what we needed over here, they enable us to synchronise across devices/sessions.
The Project
As for the prototype, I’m keeping the token list limited to the Polygon chain and the chosen bridge is Hop. The initial requirements for this project is to have a defined token list with required data — bridge address, token address and the additional information like token name, token symbol and icon to make it readable for the users.
Data: Tokens.ts
// Reference: https://github.com/hop-protocol/subgraph/blob/master/config/matic.json
const tokens: App.Token[] = [
{
address: "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063",
name: "DAI Stablecoin",
symbol: "DAI",
decimals: 18,
logoUrl: "https://assets.coingecko.com/coins/images/9956/large/4943.png",
router: '0xEcf268Be00308980B5b3fcd0975D47C4C8e1382a'
},
{
address: "0xc5102fE9359FD9a28f877a67E36B0F050d81a3CC",
name: "Hop",
symbol: "HOP",
decimals: 18,
logoUrl: "https://assets.coingecko.com/coins/images/25445/large/hop.png",
router: '0x58c61AeE5eD3D748a1467085ED2650B697A66234'
},
{
address: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",
name: "Matic",
symbol: "MATIC",
decimals: 18,
logoUrl:
"https://assets.coingecko.com/coins/images/4713/large/matic-token-icon.png?1624446912",
router: '0x553bC791D746767166fA3888432038193cEED5E2'
},
{
address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
name: "USD Coin",
symbol: "USDC",
decimals: 6,
logoUrl:
"https://assets.coingecko.com/coins/images/6319/large/USD_Coin_icon.png",
router: "0x25D8039bB044dC227f741a9e381CA4cEAE2E6aE8",
},
{
address: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
name: "Tether USD",
symbol: "USDT",
decimals: 6,
logoUrl:
"https://icons.iconarchive.com/icons/cjdowner/cryptocurrency-flat/128/Tether-USDT-icon.png",
router: "0x6c9a1ACF73bd85463A46B0AFc076FBdf602b690B",
},
{
address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
name: "Ethereum",
symbol: "ETH",
decimals: 18,
logoUrl:
"https://icons.iconarchive.com/icons/cjdowner/cryptocurrency-flat/1024/Ethereum-ETH-icon.png",
router: "0xb98454270065A31D71Bf635F6F7Ee6A518dFb849",
},
]
export default tokens
To build this Idea, I am using the following technology stack:
- MetaMask
- Protobuf
- Svelte
- TailwindCSS
- Viem
- Waku
As the main purpose of this blog is to showcase the use case of Waku, I will talk more about Waku setup and assume readers are already familiar with Svelte and other frameworks.
Let’s start with Svelte installation, along with ESLint, Prettier and TypeScript.
Before moving on, let’s look into the UI & UX.
Elements:
- Wallet Connect Button
- Peer Connection Badge
- Token List with approval/unapproval buttons
- Last synced from Waku
- Toast to show Waku message notification
User flow:
Approval flow with Waku protocols:
As we are gonna deal with smart contract addresses the core thing that we would need is a wallet connection and disconnection facility, so that a user can perform transactions.
I’m going to use MetaMask wallet (chrome extension only).
References:
And Viem, which takes care of batching, reading and writing contracts. In our case, we need this to grant and revoke allowance and read token contracts.
Utils: approvals.ts
import type { Address } from 'viem';
import { erc20ABI } from './erc20ABI';
import { publicClient, walletClient } from './client';
import { DEFAULT_ALLOWANCE_VALUE, MAX_ALLOWANCE_VALUE } from './constants';
export const isApproved = async (tokenAddress: Address, walletAddress: Address, spender: Address): Promise<boolean> => {
const result = await publicClient.readContract({
abi: erc20ABI,
account: walletAddress,
address: tokenAddress,
functionName: 'allowance',
args: [walletAddress, spender]
}) as string
return BigInt(result) !== BigInt(0)
}
export const grantApproval = async (tokenAddress: Address, walletAddress: Address, spender: Address): Promise<void> => {
const { request } = await publicClient.simulateContract({
account: walletAddress,
address: tokenAddress,
abi: erc20ABI,
functionName: 'approve',
args: [spender, MAX_ALLOWANCE_VALUE]
})
await walletClient.writeContract(request)
}
export const revokeApproval = async (tokenAddress: Address, walletAddress: Address, spender: Address): Promise<void> => {
const { request } = await publicClient.simulateContract({
account: walletAddress,
address: tokenAddress,
abi: erc20ABI,
functionName: 'approve',
args: [spender, DEFAULT_ALLOWANCE_VALUE]
})
await walletClient.writeContract(request)
}
export const allTokenApprovals = async (tokens: App.Token[], walletAddress: Address) => {
const baseContractObj = {
abi: erc20ABI,
account: walletAddress,
} as const
const contracts = tokens.map(token => {
return {
...baseContractObj,
address: token.address,
functionName: 'allowance',
args: [walletAddress, token.router]
}
})
return await publicClient.multicall({ contracts })
}
As is mentioned in the flowchart, Waku requires a few setup steps before utilising sending and filtering functions.
Create a Waku lightnode
import { createLightNode } from "@waku/sdk";
export const wakuNode = await createLightNode({
defaultBootstrap: true,
})
Wait for Peers
import { Protocols, waitForRemotePeer } from "@waku/sdk";
export const waitForRemotePeers = async () => {
// Wait for a successful peer connection
await waitForRemotePeer(wakuNode, [
Protocols.LightPush,
Protocols.Filter,
]);
}
Encoder & Decoder
Refer to the Waku docs for content-topic naming format: https://docs.waku.org/overview/concepts/content-topics
import { createEncoder, createDecoder } from "@waku/sdk";
// Choose a content topic
const contentTopic = "/bridge-token-approvals/1/approvals/proto";
// message encoder and decoder
export const encoder = createEncoder({ contentTopic, ephemeral: true });
export const decoder = createDecoder(contentTopic);
Protobuf
- Define a protobuf schema
import protobuf from "protobufjs";
// Message structure with Protobuf
export const TokenApprovalWakuMessage = new protobuf.Type('TokenApproval')
.add(new protobuf.Field('result', 1, 'string'))
}
- Serialisation of message with protobuf schema before sending it
import { Message } from "protobufjs";
export const serializeMessage = (protoMessage: Message) => {
return TokenApprovalWakuMessage.encode(protoMessage).finish()
}
Subscribe and Unsubscribe
// Subscribe & Unsubscribe to content topics
export let unsubscribeTopic: Unsubscribe = () => {}
export const subscribeTopic = async () => {
unsubscribeTopic = await wakuNode.filter.subscribe([decoder], callback)
}
The subscribe
method returns the unsubscribe
method, but in my case I segregate these methods and import them on demand.
Sender
Here is where the polling runs and does batch calling to all the token contracts and sends to the Waku topic that we have created.
import { get } from 'svelte/store'
import { walletAddress } from "$lib/store";
import { MILLISECONDS_IN_ONE_MINUTE } from "$lib/constants";
import { allTokenApprovals } from '$lib/approvals';
import tokens from '$lib/tokens';
import type { Address } from 'viem';
import { TokenApprovalWakuMessage, serializeMessage } from '$lib/waku/protobuf';
import { encoder, wakuNode } from '$lib/waku';
export let interval: NodeJS.Timeout;
export const scheduleApprovalsFetching = () => {
const address = get(walletAddress) as Address
const intervalHandler = () => {
allTokenApprovals(tokens, address).then((data) => {
const message = data.map((token, index) => ({
token: tokens[index].name,
isApproved: !!token.result
}))
const stringifiedList = JSON.stringify(message)
const protoData = TokenApprovalWakuMessage.create({ result: stringifiedList })
return wakuNode.lightPush.send(encoder, { payload: serializeMessage(protoData) })
}).catch(console.error)
}
interval = setInterval(intervalHandler, MILLISECONDS_IN_ONE_MINUTE)
}
Receiver
The receiver uses a callback
where you can implement your business logic. In my case I will simply update the state so that the frontend will re-render.
import { decoder, wakuNode } from "$lib/waku";
import type { IMessage, Unsubscribe } from "@waku/sdk";
import { TokenApprovalWakuMessage } from "$lib/waku/protobuf";
import { get } from 'svelte/store'
import { lastUpdated, showWakuToast, tokenStatusList } from "$lib/store";
export const callback = (wakuMessage: IMessage) => {
// Check if there is a payload on the message
if (!wakuMessage.payload) return;
const messageObj = TokenApprovalWakuMessage.decode(wakuMessage.payload).toJSON();
const storedList = get(tokenStatusList)
const stringifiedList = JSON.stringify(storedList)
if (storedList.length && messageObj.result !== stringifiedList)
showWakuToast.set(true)
const result = JSON.parse(messageObj.result ?? '[]');
tokenStatusList.set(result)
lastUpdated.set(new Date().toString())
localStorage.setItem('lastSynced', new Date().toString());
};
// Subscribe & Unsubscribe to content topics
export let unsubscribeTopic: Unsubscribe = () => { }
export const subscribeTopic = async () => {
unsubscribeTopic = await wakuNode.filter.subscribe([decoder], callback)
}
Now that we have our backend polling logic and Waku setup ready, lets import these functions in the frontend. As I mentioned earlier, wallet connection is the gateway to do transactions, we will be calling these functions on the wallet connection and disconnection.
Function: connectWallet
async function connectWallet() {
if (!walletClient) {
return;
}
const [address] = await walletClient.request({ method: 'eth_requestAccounts' });
walletAddress.set(address);
localStorage.setItem('userWalletAddress', address);
await establishWakuConnection(erval(interval);
}
Function: disconnectWallet
async function disconnectWallet() {
walletAddress.set(null);
localStorage.removeItem('userWalletAddress');
localStorage.removeItem('lastSynced');
// !DEBT: always use dynamic import once node has started else it throws undefined error
import('$lib/backend/receiver').then((data) => data.unsubscribeTopic()).catch(console.error);
// stop waku's light node
await wakuNode.stop();
wakuNodeStatus.set('disconnected');
clearInterval(interval);
}
Function: establishWakuConnection
async function establishWakuConnection() {
wakuNodeStatus.set('connecting');
// start waku's light node
wakuNode
.start()
.then(() => {
if (wakuNode.isStarted()) return waitForRemotePeers();
})
.then(() => {
return wakuNode.connectionManager.getPeersByDiscovery();
})
.then((data) => {
if (
wakuNode.libp2p.getConnections().length ||
data.CONNECTED.bootstrap.length ||
data.CONNECTED['peer-exchange'].length
) {
// !DEBT: always use dynamic import once node has started else it throws undefined error
import('$lib/backend/receiver').then((data) => data.subscribeTopic());
wakuNodeStatus.set('connected');
scheduleApprovalsFetching();
}
})
.catch((err) => {
console.error(err);
wakuNodeStatus.set('failed');
});
}
You will notice the code blocks above uses other state values like wakuNodeStatus
or lastSynced
these are metrics to show the user the peer connection status and last time we received a message ove Waku.
Wrap
And it’s a wrap. Some of you may yell at screen that this problem can be solved in other ways. Yes it is, but the goal over here is to show how Waku plays key role in DApps irrespective of the size of the application. Additionally, users of DApp’s may look for complete decentralisation, if it misses then companies may loose their customers. So, to keep fully decentralise setup for your DApp one of the important characteristic is decentralised communication. By integrating Waku, it enables decentralised communication features to your application without compromising security or privacy or may be scalability if you are integrating in large applications.
Do checkout the usecases over here: https://docs.waku.org/overview/use-cases
Links
- GitHub Repository: https://github.com/5war00p/bridge-token-approvals/
- Loom Video: https://www.loom.com/share/8e52ac7ffffa44d4adc7ce869d315f6d?sid=6c05627b-90ea-4e49-b78a-65780e22c0c2
- Vercel Deployed URL: https://bridge-token-approvals.vercel.app/
- IPFS Deployed URL: https://k51qzi5uqu5dj57htsj70dwksocisgckponvlw3o1476p888d3aebuq5q22s6b.ipns.4everland.io/
References
- [Svelte](https://svelte.dev/docs/introduction)
- [Waku](https://docs.waku.org/)
- [Protobuf.js](https://protobufjs.github.io/protobuf.js/)
- [Viem](https://viem.sh/docs/getting-started.html)
Learn like a newbie, apply like a pro!