Create a multichain wallet that displays realtime balances, transactions, and NFTs using Sim APIs and Express.js
The final UI we'll build together in this guide
This is the first guide in our series on building a realtime, multichain wallet using Sim APIs.
In this one, we will lay the foundation for our wallet.
You will set up a Node project with Express.js, fetch and display token balances from 60+ EVM chains using the Balances API, and calculate the wallet’s total portfolio value in USD.
Before diving into building, you can interact with the finished wallet app below.
Enter any Ethereum wallet address to explore its token balances, transaction history, and NFT collection across multiple chains.
Let’s start by scaffolding our project.
This initial setup will provide a basic Express.js server and frontend templates, allowing us to focus on integrating Sim APIs.
1
Create Your Project Structure
Open your terminal and create a new directory for the project:
Copy
Ask AI
mkdir wallet-uicd wallet-ui
Now you are in the wallet-ui directory.
Next, initialize a new Node.js project with npm:
Copy
Ask AI
npm init -ynpm pkg set type="module"
These commands create a package.json file with default values and configure it to use ES modules.
Afterward, install the required packages:
Copy
Ask AI
npm install express ejs dotenv numbro
We are using three packages for our wallet:
Express.js: A popular Node.js web framework for creating our server.
EJS: A simple templating engine that lets us generate dynamic HTML.
dotenv: A package to load environment variables from a .env file.
numbro: For formatting numbers and currency.
2
Configure Environment Variables
Create a new .env file in your project root:
Copy
Ask AI
touch .env
Open the .env file in your code editor and add your Sim API key:
.env
Copy
Ask AI
# Your Sim API key (required)SIM_API_KEY=your_api_key_here
Never commit your .env file to version control. If you are using Git, add .env to your .gitignore file.
3
Add Starter Code
Create the necessary directories for views and public assets:
Copy
Ask AI
mkdir viewsmkdir public
views will hold our EJS templates, and public will serve static assets like CSS.
Now, create the core files:
Populate server.js with this basic Express server code:
server.js
Copy
Ask AI
import express from 'express';import numbro from 'numbro';import dotenv from 'dotenv';import path from 'path';import { fileURLToPath } from 'url';// Load environment variablesdotenv.config();const SIM_API_KEY = process.env.SIM_API_KEY;if (!SIM_API_KEY) { console.error("FATAL ERROR: SIM_API_KEY is not set in your environment variables."); process.exit(1);}// Set up __dirname for ES modulesconst __filename = fileURLToPath(import.meta.url);const __dirname = path.dirname(__filename);// Initialize Expressconst app = express();// Configure Express settingsapp.set('view engine', 'ejs');app.set('views', path.join(__dirname, 'views'));app.use(express.static(path.join(__dirname, 'public')));// Add our home routeapp.get('/', async (req, res) => { const { walletAddress = '', tab = 'tokens' // Default to tokens tab } = req.query; let tokens = []; let totalWalletUSDValue = 0; let activities = []; // For Guide 2 let collectibles = []; // For Guide 3 // In later steps, these arrays will be populated with API data. res.render('wallet', { walletAddress: walletAddress, currentTab: tab, totalWalletUSDValue: '$0.00', // Will be calculated later in this guide. tokens: tokens, activities: activities, collectibles: collectibles });});// Start the serverapp.listen(3001, () => { console.log(`Server running at http://localhost:3001`);});
Add the initial frontend template to views/wallet.ejs:
:root {--font-primary: 'IBM Plex Sans', sans-serif;--font-mono: 'IBM Plex Mono', monospace;--color-bg-deep: #e1e2f9;--color-bg-container: #141829;--color-border-primary: #2c3040;--color-border-secondary: #222636;--color-text-primary: #ffffff;--color-text-secondary: #e0e0e0;--color-text-muted: #a0a3b1;--color-text-subtle: #808391;--color-accent-green: #50e3c2;--color-accent-purple: #7e87ef;--color-accent-red: #ff7875;--color-placeholder-bg: #3a3f58;}body {font-family: var(--font-primary);background-color: var(--color-bg-deep);color: var(--color-text-secondary);margin: 0;padding: 0;display: flex;justify-content: center;align-items: center;min-height: 100vh;padding-top: 0;padding-bottom: 0;height: 100vh;}.mobile-container {width: 100%;max-width: 420px;height: 90vh;max-height: 800px;min-height: 600px;background-color: var(--color-bg-container);border-radius: 20px;display: flex;flex-direction: column;overflow: hidden;align-self: center;box-shadow: 0 8px 32px rgba(20, 24, 41, 0.18), 0 1.5px 6px rgba(20, 24, 41, 0.10);}/* Header Styles */.app-header {display: flex;justify-content: space-between;align-items: center;padding: 20px;border-bottom: 1px solid var(--color-border-primary);flex-shrink: 0;}.profile-pic-placeholder {width: 36px;height: 36px;background-color: var(--color-placeholder-bg);border-radius: 50%;}.header-title {font-family: var(--font-primary);font-size: 1.4em;font-weight: 600; /* IBM Plex Sans SemiBold */color: var(--color-text-primary);}.settings-icon {width: 22px;height: 22px;background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23e0e0e0'%3E%3Cpath d='M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12-.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z'/%3E%3C/svg%3E");background-repeat: no-repeat;background-size: contain;cursor: pointer;opacity: 0.8;}/* Total Balance Section */.total-balance-section {padding: 25px 20px;text-align: center;border-bottom: 1px solid var(--color-border-primary);flex-shrink: 0;}.total-balance-amount {font-family: var(--font-mono); /* Mono for large number */font-size: 2.3em;font-weight: 700;margin: 0;color: var(--color-accent-green);}.total-balance-label {font-family: var(--font-primary);font-size: 0.85em;color: var(--color-text-muted);margin-top: 2px;cursor: pointer; /* Make it look clickable */}.total-balance-label:hover { color: var(--color-text-primary);}/* Tabs Section */.tabs {display: flex;border-bottom: 1px solid var(--color-border-primary);flex-shrink: 0;position: relative;z-index: 1;}.tab-button {font-family: var(--font-primary);flex-grow: 1;padding: 14px;text-align: center;cursor: pointer;background-color: transparent;border: none;color: var(--color-text-muted);font-size: 0.95em;font-weight: 500; /* IBM Plex Sans Medium */border-bottom: 3px solid transparent;transition: color 0.2s ease, border-bottom-color 0.2s ease;}.tab-button:hover {color: var(--color-text-primary);}.tab-button.active {color: var(--color-text-primary);border-bottom: 3px solid var(--color-accent-purple);}.tab-content {padding: 0px 20px 20px 20px;flex-grow: 1;min-height: 0;max-height: calc(100vh - 220px);overflow-y: auto;}.tab-pane { display: none; }.tab-pane.active { display: block; }/* Tokens Tab & Activity Tab Styles */.list-item {display: flex;align-items: center;padding: 16px 0;border-bottom: 1px solid var(--color-border-secondary);}.list-item:last-child { border-bottom: none; }.item-icon-placeholder {width: 38px;height: 38px;background-color: #2c3040; /* Using a specific color, not var for contrast */border-radius: 50%;margin-right: 15px;display: flex;justify-content: center;align-items: center;font-family: var(--font-mono); /* Mono for symbols like ETH */font-weight: 500;font-size: 0.9em;color: #c0c3d1; /* Using specific color */flex-shrink: 0;overflow: hidden; /* Prevents text overflow if symbol is too long */}.item-info {flex-grow: 1;min-width: 0; /* Prevents text overflow issues in flex children */}.item-name { /* Token Name, Activity Type like "Received", "Sent" */font-family: var(--font-primary);font-size: 1.05em;font-weight: 500; /* IBM Plex Sans Medium */margin: 0 0 3px 0;color: var(--color-text-primary);white-space: nowrap;overflow: hidden;text-overflow: ellipsis;}.item-sub-info { /* "1.2345 ETH on Ethereum", "Price: $800.00" */font-family: var(--font-primary); /* Sans for descriptive part */font-size: 0.85em;color: var(--color-text-muted);margin: 0;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;}.item-sub-info .mono { /* Span class for monospaced parts within sub-info */font-family: var(--font-mono);}.item-value-right {text-align: right;flex-shrink: 0;padding-left: 10px;}.item-usd-value { /* USD value of holding */font-family: var(--font-mono); /* Mono for numerical USD value */font-size: 1.05em;font-weight: 500;margin: 0 0 3px 0;color: var(--color-text-primary);}/* Activity Tab Specifics */.activity-direction { /* "Received ETH", "Sent USDC" */font-family: var(--font-primary);font-size: 1.05em;font-weight: 500; /* IBM Plex Sans Medium */margin: 0 0 3px 0;}.activity-direction.sent { color: var(--color-accent-red); }.activity-direction.receive { color: var(--color-accent-green); } /* Ensure class name consistency with JS */.activity-address, .activity-timestamp {font-family: var(--font-primary); /* Sans for "From:", "To:" */font-size: 0.8em;color: var(--color-text-subtle);margin: 0;}.activity-address .mono, .activity-timestamp .mono { /* For address itself and timestamp value */ font-family: var(--font-mono);}.activity-amount-right { /* "+0.5 ETH" */font-family: var(--font-mono); /* Mono for amount + symbol */font-size: 1.05em;font-weight: 500;}.activity-amount-right.sent { color: var(--color-accent-red); }.activity-amount-right.receive { color: var(--color-accent-green); }/* NFT Grid Container */.collectibles-grid { display: grid; grid-template-columns: repeat(1, 1fr); gap: 1rem; padding-top: 20px; width: 100%;}/* NFT Item Container */.collectible-item-link { text-decoration: none; color: inherit; display: block; transition: transform 0.2s ease;}.collectible-item-link:hover { transform: translateY(-2px);}.collectible-item { position: relative; border-radius: 12px; overflow: hidden; background: var(--color-bg-container); border: 1px solid var(--color-border-secondary); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);}/* Image Container */.collectible-image-container { position: relative; width: 100%; padding-bottom: 100%; /* Creates a square aspect ratio */ background: var(--color-placeholder-bg);}.collectible-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;}/* Image Placeholder */.collectible-image-placeholder { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-family: var(--font-primary); font-weight: 600; font-size: 0.875rem;}/* NFT Info Container - Static (always visible) */.collectible-info-static { padding: 0.75rem; background: var(--color-bg-container); border-top: 1px solid var(--color-border-secondary);}/* NFT Info Container - Hover (keep for backwards compatibility but not used) */.collectible-info { position: absolute; bottom: 0; left: 0; right: 0; padding: 0.75rem; background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); color: white; opacity: 0; transition: opacity 0.3s ease;}.collectible-item-link:hover .collectible-info { opacity: 1;}/* NFT Text Styles */.collectible-name { font-family: var(--font-primary); font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--color-text-primary);}.collectible-collection { font-family: var(--font-mono); font-size: 0.8rem; margin-bottom: 0; opacity: 0.9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--color-text-muted);}.collectible-chain { font-family: var(--font-primary); font-size: 0.7rem; opacity: 0.8; color: white;}
4
Verify Project Structure
Run ls in your terminal. Your project directory wallet-ui/ should now contain:
Copy
Ask AI
wallet-ui/├── server.js # Main application file with Express server├── views/ # Directory for EJS templates│ └── wallet.ejs # Main wallet UI template├── public/ # Directory for static assets│ └── styles.css # CSS styling for the wallet UI├── package.json # Project configuration├── package-lock.json # Dependency lock file (if `npm install` was run)├── node_modules/ # Installed packages (if `npm install` was run)└── .env # Your environment variables
Run node server.js in the terminal to start the server.
Visit http://localhost:3001 to see the blank wallet.
Our scaffolded wallet UI without any data.
If you encounter errors, ensure your .env file contains the correct SIM_API_KEY and that it is loaded correctly.
Also, verify the walletAddress in the URL is a valid EVM wallet address.
Check your terminal for any error messages from server.js.
Now, let’s integrate the Sim API to fetch real data.
We will use the Balances API to get realtime token balances for a given wallet address.
This endpoint provides comprehensive details about native and ERC20 tokens, including metadata and USD values across more than 60+ EVM chains.
First, let’s create an async function in server.js to fetch these balances. Add this function before your app.get('/') route handler:
server.js (getWalletBalances)
Copy
Ask AI
async function getWalletBalances(walletAddress) { if (!walletAddress) return []; // Return empty if no address // Construct the query parameters // metadata=url,logo fetches token URLs and logo images const queryParams = `metadata=url,logo`; const url = `https://api.sim.dune.com/v1/evm/balances/${walletAddress}?${queryParams}`; try { const response = await fetch(url, { headers: { 'X-Sim-Api-Key': SIM_API_KEY, // Your API key from .env 'Content-Type': 'application/json' } }); if (!response.ok) { const errorBody = await response.text(); console.error(`API request failed with status ${response.status}: ${response.statusText}`, errorBody); throw new Error(`API request failed: ${response.statusText}`); } const data = await response.json(); // The API returns JSON with a "balances" key. We return that directly. return data.balances; } catch (error) { console.error("Error fetching wallet balances:", error.message); return []; // Return empty array on error }}
This function creates the API request using Node’s fetch.
It includes your SIM_API_KEY in the headers and sends a GET request to the /v1/evm/balances/{address} endpoint.
The Balances API gives you access to various URL query parameters that you can include to modify the response.
We have included metadata=url,logo to include a token’s URL and logo.
Next, modify your app.get('/') route handler in server.js to call getWalletBalances and pass the fetched tokens to your template:
server.js
Copy
Ask AI
app.get('/', async (req, res) => { const { walletAddress = '', tab = 'tokens' } = req.query; let tokens = []; let totalWalletUSDValue = 0; let errorMessage = null; if (walletAddress) { try { tokens = await getWalletBalances(walletAddress); } catch (error) { console.error("Error in route handler:", error); errorMessage = "Failed to fetch wallet data. Please try again."; // tokens will remain empty, totalWalletUSDValue will be 0 } } res.render('wallet', { walletAddress: walletAddress, currentTab: tab, totalWalletUSDValue: `$0.00`, // We'll calculate this in the next section tokens: tokens, activities: [], // Placeholder for Guide 2 collectibles: [], // Placeholder for Guide 3 errorMessage: errorMessage });});
We’ve updated the route to:
Call getWalletBalances if a walletAddress is provided.
Pass the retrieved balances to the wallet.ejs template.
The Balances API returns token amounts in their smallest denomination. This will be in wei for ETH-like tokens.
To display these amounts in a user-friendly way, like 1.23 ETH instead of 1230000000000000000 wei, we need to adjust the amount using the token’s decimals property, which is also returned from the Balances API.
We can add a new property, balanceFormatted, to each token object.
Modify your getWalletBalances function in server.js as follows. The main change is mapping over data.balances to add the balanceFormatted property:
server.js (getWalletBalances with formatting)
Copy
Ask AI
async function getWalletBalances(walletAddress) { if (!walletAddress) return []; const queryParams = `metadata=url,logo`; const url = `https://api.sim.dune.com/v1/evm/balances/${walletAddress}?${queryParams}`; try { const response = await fetch(url, { headers: { 'X-Sim-Api-Key': SIM_API_KEY, 'Content-Type': 'application/json' } }); if (!response.ok) { const errorBody = await response.text(); console.error(`API request failed with status ${response.status}: ${response.statusText}`, errorBody); throw new Error(`API request failed: ${response.statusText}`); } const data = await response.json(); // Return formatted values and amounts return (data.balances || []).map(token => { // 1. Calculate human-readable token amount const numericAmount = parseFloat(token.amount) / Math.pow(10, parseInt(token.decimals)); // 2. Get numeric USD value const numericValueUSD = parseFloat(token.value_usd); // 3. Format using numbro const valueUSDFormatted = numbro(numericValueUSD).format('$0,0.00'); const amountFormatted = numbro(numericAmount).format('0,0.[00]A'); return { ...token, valueUSDFormatted, amountFormatted }; }).filter(token => token.symbol !== 'RTFKT'); // Removing Spam Tokens. Add more if you like. } catch (error) { console.error("Error fetching wallet balances:", error.message); return []; }}
Now, each token object returned by getWalletBalances will include a balanceFormatted string, which our EJS template (views/wallet.ejs) already uses: <%= token.balanceFormatted || token.amount %>.
Restart the server and refresh the browser. You will now see formatted balances.
Wallet displaying properly formatted token balances with logos.
The wallet’s total value at the top of the UI still says $0.00.
Let’s calculate the total USD value of the wallet and properly show it.
The Balances API provides a value_usd field with each token.
This field represents the total U.S. dollar value of the wallet’s entire holding for that specific token.
Let’s modify the app.get('/') route handler to iterate through the fetched tokens and sum their individual value_usd to calculate the totalWalletUSDValue.
server.js (app.get('/') with total value calculation)
Copy
Ask AI
app.get('/', async (req, res) => { const { walletAddress = '', tab = 'tokens' } = req.query; let tokens = []; let totalWalletUSDValue = 0; // Will be updated let errorMessage = null; if (walletAddress) { try { tokens = await getWalletBalances(walletAddress); // Calculate the total USD value from the fetched tokens if (tokens && tokens.length > 0) { tokens.forEach(token => { let individualValue = parseFloat(token.value_usd); if (!isNaN(individualValue)) { totalWalletUSDValue += individualValue; } }); } totalWalletUSDValue = numbro(totalWalletUSDValue).format('$0,0.00'); } catch (error) { console.error("Error in route handler:", error); errorMessage = "Failed to fetch wallet data. Please try again."; // tokens will remain empty, totalWalletUSDValue will be 0 } } res.render('wallet', { walletAddress: walletAddress, currentTab: tab, totalWalletUSDValue: totalWalletUSDValue, // Pass the calculated total tokens: tokens, activities: [], // Placeholder for Guide 2 collectibles: [], // Placeholder for Guide 3 errorMessage: errorMessage });});
We use the reduce method to iterate over the tokens array.
For each token, we access its value_usd property, parse it as a float, and add it to the running sum.
The calculated totalWalletUSDValue is then formatted to two decimal places and passed to the template.
The views/wallet.ejs template already has <p class="total-balance-amount"><%= totalWalletUSDValue %></p>, so it will display the calculated total correctly.
Restart your server and refresh the browser page with a wallet address.
You should now see the total wallet value at the top of the UI accurately reflecting the sum of all token balance USD values.
Wallet showing the correctly calculated total USD value.
You have successfully set up the basic structure of your multichain wallet and integrated Sim APIs Balances API endpoint to display realtime token balances and total portfolio value.
In the next guide, Add Account Activity, we will enhance this wallet by adding a transaction history feature in the UI using the Activity API.
Assistant
Responses are generated using AI and may contain mistakes.