How to Add a Dapp Playground Mode to Uniswap’s Interface
Migrate to Virtual TestNets
For new projects, we recommend starting with TestNets.
For automatic migration of Forks to TestNets, .
In this guide, we’ll walk you through the process of creating a dapp playground mode and adding it to Uniswap’s open-source UI, using Tenderly Forks as the core infrastructure. The publicly shared Fork used in this example is accessible here, where you can view transactions and inspect them using Debugger without a Tenderly account.
To preview the end result, check out the video below, which shows you how playground mode in Uniswap’s UI works.
You can find the complete demo repository on GitHub.
This tutorial is for demonstration purposes only, specifically to illustrate how to add a playground capability to a dapp. It is not intended nor recommended for any other usage.
Why do dapps need playground mode?
Adding playground mode to dapps brings about two key benefits: building trust with end users and enabling easier onboarding to your dapp. Playground mode provides a safe environment where users can familiarize themselves with the features and core functionality of your dapp without risking real funds.
Transactions in playground mode are simulated within a virtual environment, protecting users from costly mistakes. In fact, playground mode can help users identify potential errors before they make it to the blockchain.
Beyond building trust, playground mode can also be helpful with easier onboarding of users to your dapp. It can make your dapp more approachable, drawing in new users. Furthermore, it can help users discover new ways to use your dapp, helping them tailor their interactions to suit their specific needs.
How to make a dapp playground mode?
Dapps operating in playground mode never communicate with the live network. Instead, they interact with a cloned and isolated version of the blockchain’s most recent state, referred to as a Fork.
This is illustrated in the diagram below, with the playground mode depicted by yellow lines and the live network by grey lines.
Upon entering playground mode, users are given a virtualized environment. Here, they can perform and test transactions without any risk as they are working with a replica of their chosen network.
To implement playground mode, dapps can use Tenderly Forks as the underlying infrastructure. All actions carried out in playground mode occur on a real-time replica of the latest state of the network, providing a realistic user experience.
Moreover, Tenderly provides a transaction explorer where users can review simulated transactions. These transactions can be kept private or shared publicly. This way, dapp users get all the tools to interact with the simulated dapp in familiar ways, resembling the live project.
Uniswap playground mode - Project overview
As mentioned above, we’ll be adding a playground mode option to the Uniswap UI.
If you want to replicate the steps outlined in this tutorial, you should be familiar with React and Tenderly API.
Here’s what we’ll need to do:
- Integrate Tenderly Fork API calls
- Create React hooks that put the dapp in playground mode. The hooks need to be able to:
- Spin up a new Tenderly Fork through the API when playground mode activated
- Add the Fork URL to the wallet (we’ll be using MetaMask)
- Use Tenderly’s custom Fork JSON RPC call
tenderly_setBalance
to top up the user’s account
- Create the Playground Controls Component
- Add the Playground Component to the UI
- Let the user know when playground mode is enabled
Let’s start building! 👷‍♂️
Step 1: Clone the Uniswap UI from GitHub
Clone the GitHub repo that contains Uniswap’s open-source UI and install dependencies by running the following commands:
git clone https://github.com/Uniswap/interface.git
cd interface
yarn
You can find the complete repository on GitHub, including the changes we omitted from this guide.
Step 2: Build out the logic for interacting with the Tenderly API
Write the logic to interact with the Tenderly API to handle the process of spinning up and deleting Tenderly Forks.
First, create a file called tenderly-fork-api.ts
, where we’ll store the logic.
touch tenderly-fork-api.ts
Copy the code below and paste it into the file we created.
The provided JavaScript code exports two primary elements: aTenderlyFork
function and TenderlyForkProvider
type. It also utilizes the @ethersproject/providers
and axios
libraries and uses environment variables to set certain parameters.
Here is a breakdown of the main components:
aTenderlyFork
function: This async function takes aTenderlyForkRequest
object as an argument and returns aTenderlyForkProvider
object. This function:- Sends a
POST
request to Tenderly’s API to create a new fork. - Retrieves the ID of the fork created from the response data.
- Generates a
rpcUrl
for the fork and uses it to create aJsonRpcProvider
. - Creates
privateUrl
andpublicUrl
(URL that can be shared publicly) for the fork dashboard. - Returns an object of type
TenderlyForkProvider
with all the relevant details of the fork, including aremoveFork
method.
- Sends a
removeFork
function: This async function takesforkId
as an argument and sends aDELETE
request to Tenderly’s API to remove a fork with the given ID.tenderlyBaseApi
instance: This is an instance of Axios with pre-configuredbaseURL
and headers for communicating with Tenderly’s API.
import { JsonRpcProvider } from '@ethersproject/providers';
import axios, { AxiosResponse } from 'axios';
const {
REACT_APP_TENDERLY_PROJECT_SLUG,
REACT_APP_TENDERLY_ACCESS_KEY,
REACT_APP_TENDERLY_USERNAME,
} = process.env;
export async function aTenderlyFork(fork: TenderlyForkRequest): Promise<TenderlyForkProvider> {
const forkResponse = await tenderlyBaseApi.post(
`account/${REACT_APP_TENDERLY_USERNAME}/project/${REACT_APP_TENDERLY_PROJECT_SLUG}/fork/`,
fork,
);
const forkId = forkResponse.data.root_transaction.fork_id;
const rpcUrl = `https://rpc.tenderly.co/fork/${forkId}`;
const forkProvider = new JsonRpcProvider(rpcUrl);
const blockNumberStr = (forkResponse.data.root_transaction.receipt.blockNumber as string).replace(
'0x',
'',
);
const blockNumber = Number.parseInt(blockNumberStr, 16);
const privateUrl = `https://dashboard.tenderly.co/account/${REACT_APP_TENDERLY_USERNAME}/project/${REACT_APP_TENDERLY_PROJECT_SLUG}/fork/${forkId}`;
const publicUrl = `https://dashboard.tenderly.co/shared/fork/${forkId}/transactions`;
console.info(
`\nForked ${fork.network_id}
at block ${blockNumber}
with chain ID ${fork.chain_config?.chain_id}
fork ID: ${forkId}
`,
);
console.info('Fork:', privateUrl);
return {
rpcUrl,
provider: forkProvider,
blockNumber,
forkUUID: forkId,
removeFork: () => removeFork(forkId),
networkId: fork.network_id as any,
publicUrl,
privateUrl,
chainId: forkResponse.data.simulation_fork.chain_config.chain_id,
baseChainId: fork.network_id,
};
}
async function removeFork(forkId: string) {
console.log('Removing test fork', forkId);
return await tenderlyBaseApi.delete(
`account/${REACT_APP_TENDERLY_USERNAME}/project/${REACT_APP_TENDERLY_PROJECT_SLUG}/fork/${forkId}`,
);
}
const tenderlyBaseApi = axios.create({
baseURL: `https://api.tenderly.co/api/v1/`,
headers: {
'X-Access-Key': REACT_APP_TENDERLY_ACCESS_KEY || '',
'Content-Type': 'application/json',
},
});
type TenderlyForkRequest = {
shared?: boolean;
block_number?: number;
network_id: string;
transaction_index?: number;
initial_balance?: number;
alias?: string;
description?: string;
chain_config?: {
chain_id?: number;
homestead_block?: number;
dao_fork_support?: boolean;
eip_150_block?: number;
eip_150_hash?: string;
eip_155_block?: number;
eip_158_block?: number;
byzantium_block?: number;
constantinople_block?: number;
petersburg_block?: number;
istanbul_block?: number;
berlin_block?: number;
};
};
export type TenderlyForkProvider = {
rpcUrl: string;
provider: JsonRpcProvider;
forkUUID: string;
blockNumber: number;
chainId: number;
networkId: number;
publicUrl: string;
privateUrl: string;
baseChainId: string;
/**
* map from address to given address' balance
*/
removeFork: () => Promise<AxiosResponse<any>>;
};
Step 3: Add React hooks to enable playground mode
Create a file with the name useTenderlyFork.ts
and paste the code below into it.
This block of code contains React hooks that will be used to manage Tenderly Forks (i.e., replicas of the latest state of the chosen blockchain).
The most important hooks and functions to take note of include:
useActiveTenderlyFork
function: Returns an object containing information about the current fork, including whether a fork is active and the fork’s provider (an instance of TenderlyForkProvider or undefined).useTenderlyPlayground
function: Returns an object containing methods and data to manage a blockchain fork for testing or playground purposes. It includes:- playgroundProvider: The current fork provider.
- isPlayground: A boolean indicating whether a fork is currently active.
- aNewPlayground: A method for creating a new fork.
- discardPlayground: A method for removing the current fork.
useNewFork
function: This function is used to create a new fork of the blockchain. If there is an existing fork, it attempts to remove it first. Then, it creates a new fork using the specified or current chain ID and sets this new fork as the current provider. The new fork is also added to MetaMask, and the connected signer is topped up with Ether for testing purposes.topUpConnectedSigner
function: Used to provide a balance of Ether to the connected signer on the newly forked blockchain.addForkToMetamask
function: Adds the newly created fork to Metamask, an Ethereum wallet.useRemoveFork
function: Removes the current fork of the blockchain.
When simulating a network using Tenderly Forks, we’re basing it on an existing network. To prevent
a transaction replay
attack, we must
specify the chainId
that is different from the original network’s chainId
. This is why we’re
prefixing chainId
before calling the Tenderly Fork API. We’re using
theTENDERLY_CHAIN_FORK_PREFIX
, which has an arbitrary value of 7340317
.
import { useWeb3React } from '@web3-react/core';
import { aTenderlyFork, TenderlyForkProvider } from 'components/Web3Status/tenderly-fork-api';
import { removeTenderlyChainIdPrefix, TENDERLY_CHAIN_FORK_PREFIX } from 'constants/chains';
import { nativeOnChain } from 'constants/tokens';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { ethers } from 'ethers';
import { atom } from 'jotai';
import { useAtomValue, useUpdateAtom } from 'jotai/utils';
import { useCallback } from 'react';
const provider = atom<TenderlyForkProvider | undefined>(undefined);
const providerState = atom<'CREATING' | 'RUNNING'>('RUNNING');
export function useActiveTenderlyFork() {
return {
tenderlyFork: useAtomValue(provider) != null,
forkProvider: useAtomValue(provider) || undefined,
};
}
export function useTenderlyPlayground(): {
playgroundProvider?: TenderlyForkProvider;
isPlayground: boolean;
aNewPlayground: (chainId?: number) => Promise<void>;
discardPlayground: () => Promise<void>;
} {
const prov = useAtomValue(provider);
return {
playgroundProvider: prov,
isPlayground: !!prov,
aNewPlayground: useNewFork(),
discardPlayground: useRemoveFork(),
};
}
function useNewFork() {
const currentProvider = useAtomValue(provider);
const updateProvider = useUpdateAtom(provider);
const updateProviderState = useUpdateAtom(providerState);
const currentChainId = useWeb3React().chainId;
return useCallback(
async (chainId: number | undefined = -1) => {
if (currentProvider) {
console.log('removing provider', currentProvider.rpcUrl);
try {
await currentProvider.removeFork();
} catch (error) {
console.warn('Errored out when deleting the old fork', error);
}
} else {
console.log('no fork provider');
}
updateProviderState('CREATING');
if (chainId == -1 && currentChainId) {
chainId = currentChainId;
}
chainId = removeTenderlyChainIdPrefix(chainId);
const _forkProvider = await aTenderlyFork({
network_id: '' + chainId,
chain_config: { chain_id: Number.parseInt(`${TENDERLY_CHAIN_FORK_PREFIX}${chainId}`) },
shared: true,
});
updateProvider(_forkProvider);
await addForkToMetamask(_forkProvider);
await topUpConnectedSigner(_forkProvider);
},
[currentProvider, updateProvider, updateProviderState, currentChainId],
);
}
async function topUpConnectedSigner(forkProvider: TenderlyForkProvider) {
if (window.ethereum) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
console.log('Address', await signer.getAddress());
console.log('top up: Fork provider', forkProvider.rpcUrl);
await forkProvider.provider.send('tenderly_setBalance', [
[await signer.getAddress()],
ethers.utils.hexValue(ethers.utils.parseUnits('100', 'ether').toHexString()),
]);
const balanceRead = await forkProvider.provider.send('eth_getBalance', [
await signer.getAddress(),
'latest',
]);
console.log(`Balance of ${signer.getAddress()} is ${balanceRead}`);
}
}
async function addForkToMetamask(forkProvider: TenderlyForkProvider) {
const forkUrl = forkProvider.rpcUrl;
if (!forkUrl) {
console.log('No Fork');
return;
}
if (typeof window.ethereum !== 'undefined') {
const noc = nativeOnChain(forkProvider.chainId);
//@ts-ignore
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [
{
chainId: `0x${forkProvider.chainId.toString(16)}`,
rpcUrls: [forkUrl],
chainName: `Forked ${forkProvider.chainId}`,
nativeCurrency: {
name: noc.name,
symbol: noc.symbol,
decimals: noc.decimals,
},
blockExplorerUrls: ['https://polygonscan.com/'],
},
],
});
} else {
console.log('Metamask is not installed');
}
}
function useRemoveFork() {
const currentProvider = useAtomValue(provider);
const setProvider = useUpdateAtom(provider);
return useCallback(async () => {
await currentProvider?.removeFork();
setProvider(undefined);
}, [currentProvider, setProvider]);
}
Step 4: Create the Playground Controls Component
Next, we want to create controls that will put the dapp into playground mode. Create a file PlaygroundControls.tsx
and paste the code below.
This JavaScript code defines a React component called PlaygroundControls
, which provides a user interface for managing Tenderly Forks.
Here are the main components and their functionality:
- Hooks are used to extract the required data and functionality:
useTenderlyPlayground
: to control and manage the blockchain fork. This includes creating a new fork, checking if a fork is active, and discarding a fork.
- Button interactions are defined within
createPlayground
andconnectChain
callbacks.createPlayground
creates a new fork on click.connectChain
switches to the original blockchain and discards the current fork.
// PlaygroundControls.tsx
import { useWeb3React } from '@web3-react/core';
import useSelectChain from 'hooks/useSelectChain';
import useSyncChainQuery from 'hooks/useSyncChainQuery';
import { useTenderlyPlayground } from 'hooks/useTenderlyFork';
import useBlockNumber from 'lib/hooks/useBlockNumber';
import { useCallback, useMemo } from 'react';
import styled from 'styled-components/macro';
import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink';
export default function PlaygroundControls() {
const { chainId } = useWeb3React();
const {
aNewPlayground: aNewTenderlyForkProvider,
playgroundProvider: tenderlyForkProvider,
isPlayground,
discardPlayground,
} = useTenderlyPlayground();
const selectChain = useSelectChain();
const blockNumber = useBlockNumber();
useSyncChainQuery();
const createPlayground = useCallback(async () => {
await aNewTenderlyForkProvider(chainId);
}, [chainId, aNewTenderlyForkProvider]);
const blockExternalLinkHref = useMemo(() => {
if (!chainId || !blockNumber) {
return '';
}
if (tenderlyForkProvider) {
return tenderlyForkProvider?.publicUrl || '';
}
return getExplorerLink(chainId, blockNumber.toString(), ExplorerDataType.BLOCK);
}, [chainId, tenderlyForkProvider, blockNumber]);
const connectChain = useCallback(async () => {
if (!tenderlyForkProvider) {
return;
}
await selectChain(Number.parseInt(tenderlyForkProvider.baseChainId));
await discardPlayground();
}, [discardPlayground, tenderlyForkProvider, selectChain]);
return (
<ControlsPane>
<Button href={blockExternalLinkHref}>Explorer</Button>
{!isPlayground && <Button onClick={createPlayground}>Playground</Button>}
{isPlayground && <Button onClick={createPlayground}>Clear Playground</Button>}
{isPlayground && <Button onClick={connectChain}>Back To Network</Button>}
</ControlsPane>
);
}
const ControlsPane = styled.a`
display: inline-flex;
height: 70px;
padding: 16px;
align-items: center;
gap: 24px;
flex-shrink: 0;
border-radius: 16px;
border: 1px solid #e5e4ef;
background: #fff;
margin-top: 50px;
position: absolute;
bottom: 30px;
`;
const Button = styled.a`
display: flex;
padding: 10px 24px;
align-items: flex-start;
border-radius: 8px;
border: 1px solid #e4e3ee;
background: #f5f6fc;
box-shadow: 0px 1px 4px 0px rgba(23, 23, 24, 0.07);
border-radius: 8px;
border: 1px solid #e4e3ee;
background: #f5f6fc;
box-shadow: 0px 1px 4px 0px rgba(23, 23, 24, 0.07);
color: #38363a;
font-size: 14px;
font-family: Inter;
font-weight: 600;
line-height: 20px;
cursor: pointer;
user-select: none;
text-decoration: none;
`;
Step 5: Add the Playground Controls Component to the app
Finally, go to App.tsx
and add the PlaygroundsControl
component:
...
export default function App() {
...
return (
<ErrorBoundary>
...
<BodyWrapper>
<EnvironmentIndicator />
+ <PlaygroundControls />
...
</BodyWrapper>
</ErrorBoundary>
)
}
Step 6: Use the playground JSON RPC provider
Besides the basic setup shown in Steps 1-4, it’s necessary to ensure that switching from the public network to the fork is opaque for the registry of contracts, tokens, and other assets.
To make this tutorial work, we had to modify parts of the Uniswap codebase. We won’t be going too deep into the changes, but you can check them out on GitHub.
The following files were customized:
- constants/addresses.ts
- constants/chainInfo.ts
- constants/chains.ts
- constants/networks.ts
- constants/providers.ts
- constants/routing.ts
- constants/tokens.ts
In the Uniswap codebase, the UNIVERSAL_ROUTER_ADDRESS
function looks up the address of the UniversalRouter
based on the current chain. When switching from a chain to a fork (of that chain), the UniversalRouter
is deployed at the same address on the forked chain.
In other words, the following must hold:
UNIVERSAL_ROUTER_ADDRESS(1) == UNIVERSAL_ROUTER_ADDRESS(`${TENDERLY_CHAIN_FORK_PREFIX}1`);
More generally, all lookups and calculations that are dependent on chain ID should yield the same results when done on a prefixed (forked) chain ID:
calculation({chainId: 1})
== calculation({chainId: `${TENDERLY_CHAIN_FORK_PREFIX}1`)
Step 7: Run the app
To run the app, execute the following command:
yarn start
Conclusion
In this tutorial, we used Tenderly Forks as the foundation for building a dapp playground that we integrated into Uniswap’s UI. The playground consists of a replica of the network the user wants to interact with. After this, we added the fork’s URL to MetaMask and set the balance on the account to an arbitrary value using our custom RPC call tenderly_setBalance
.
This gave the user a simulated virtualized environment for dry-running transactions without the risk of losing real funds. Since the fork is a real-time replica of the latest state of the network, the user can be confident that the simulated results will always resemble real-world circumstances.