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.

Prerequisites

Before we begin, make sure you have:

  • Node.js >= 18.0.0
  • A Sim API key

Get your API Key

Learn how to obtain your API key to properly authenticate requests.

Features

By the end of this series, our wallet will have four main features:

  1. Token Balances: Realtime balance tracking with USD values using the Balances API.
  2. Total Portfolio Value: Aggregated USD value across all chains.
  3. Wallet Activity: Comprehensive transaction history showing transfers and contract interactions using the Activity API
  4. NFTs: A display of owned NFTs using the Collectibles API

In this first guide, we will focus on implementing the first two: Token Balances and Total Portfolio Value.

Try the Live Demo

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.

Project Setup

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:

mkdir wallet-ui
cd wallet-ui

Now you are in the wallet-ui directory. Next, initialize a new Node.js project with npm:

npm init -y
npm 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:

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:

touch .env

Open the .env file in your code editor and add your Sim API key:

.env
# 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:

mkdir views
mkdir public

views will hold our EJS templates, and public will serve static assets like CSS. Now, create the core files:

touch server.js
touch views/wallet.ejs
touch public/styles.css

Populate server.js with this basic Express server code:

server.js
import express from 'express';
import numbro from 'numbro';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';

// Load environment variables
dotenv.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 modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Initialize Express
const app = express();

// Configure Express settings
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));

// Add our home route
app.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 server
app.listen(3001, () => {
    console.log(`Server running at http://localhost:3001`);
});

Add the initial frontend template to views/wallet.ejs:

views/wallet.ejs
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Sim APIs Wallet UI - A simple and elegant wallet interface for viewing crypto assets and transactions">
    <meta name="theme-color" content="#0b0e1f">
    <title>Sim APIs Wallet UI</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="mobile-container">
    <header class="app-header">
        <div class="profile-pic-placeholder"></div>
        <div class="header-title">Wallet</div>
        <div class="settings-icon"></div>
    </header>

    <section class="total-balance-section">
        <p class="total-balance-amount"><%= totalWalletUSDValue %></p>
        <p class="total-balance-label js-wallet-address-display"><%= walletAddress || 'Enter wallet address...' %></p>
    </section>

    <nav class="tabs">
        <button class="tab-button <%= currentTab === 'tokens' ? 'active' : '' %>" data-tab="tokens">Tokens</button>
        <button class="tab-button <%= currentTab === 'activity' ? 'active' : '' %>" data-tab="activity">Activity</button>
        <button class="tab-button <%= currentTab === 'collectibles' ? 'active' : '' %>" data-tab="collectibles">Collectibles</button>
    </nav>

    <main class="tab-content">
        <!-- Tokens Tab Pane -->
        <div id="tokens" class="tab-pane <%= currentTab === 'tokens' ? 'active' : '' %>">
            <% if (tokens && tokens.length > 0) { %>
                <% tokens.forEach(token => { %>
                    <div class="list-item">
                        <% if (token.token_metadata && token.token_metadata.logo) { %>
                            <img src="<%= token.token_metadata.logo %>" alt="<%= token.symbol || 'Token' %> Logo" class="item-icon-placeholder" style="object-fit: contain; padding: 6px;">
                        <% } else { %>
                            <div class="item-icon-placeholder"><%= token.symbol ? token.symbol.substring(0, 4) : '?' %></div>
                        <% } %>
                        <div class="item-info">
                            <% if (token.token_metadata && token.token_metadata.url) { %>
                                <p class="item-name"><a href="<%= token.token_metadata.url %>" target="_blank" style="color: inherit; text-decoration: none;"><%= token.name || token.symbol %></a></p>
                            <% } else { %>
                                <p class="item-name"><%= token.name || token.symbol %></p>
                            <% } %>
                        </div>
                        <div class="item-value-right">
                            <p class="item-usd-value">
                                <strong>
                                    <%= token.valueUSDFormatted || (token.value_usd != null ? token.value_usd : 'N/A') %>
                                </strong>
                            </p>
                            <p class="item-sub-info">
                                <%= token.amountFormatted || token.amount %> <%= token.symbol %>
                            </p>
                        </div>
                    </div>
                <% }); %>
            <% } else if (walletAddress) { %>
                <p style="text-align: center; padding-top: 30px; color: var(--color-text-muted);">No tokens found for this wallet.</p>
            <% } else { %>
                <p style="text-align: center; padding-top: 30px; color: var(--color-text-muted);">Enter a wallet address above to see token balances.</p>
            <% } %>
        </div>

       <!-- Activity Tab Pane (for Guide 2) -->
        <div id="activity" class="tab-pane <%= currentTab === 'activity' ? 'active' : '' %>">
            <p style="font-family: var(--font-primary); text-align: center; padding-top: 30px; color: var(--color-text-muted);">Activity feature will be added in the next guide.</p>
        </div>

        <!-- Collectibles Tab Pane (for Guide 3) -->
        <div id="collectibles" class="tab-pane <%= currentTab === 'collectibles' ? 'active' : '' %>">
            <p style="font-family: var(--font-primary); text-align: center; padding-top: 30px; color: var(--color-text-muted);">Collectibles feature will be added in a future guide.</p>
        </div>
    </main>
</div>

<script>
    const tabButtons = document.querySelectorAll('.tab-button');
    const tabPanes = document.querySelectorAll('.tab-pane');
    const walletAddressDisplay = document.querySelector(".js-wallet-address-display");

    tabButtons.forEach(button => {
        button.addEventListener('click', () => {
            const tab = button.dataset.tab;
            const currentWalletAddress = new URLSearchParams(window.location.search).get('walletAddress');
            if (currentWalletAddress) {
                window.location.search = `?walletAddress=${currentWalletAddress}&tab=${tab}`;
            } else {
                // If no wallet address, just switch tab visually without reload, or prompt
                tabButtons.forEach(btn => btn.classList.remove('active'));
                tabPanes.forEach(pane => pane.classList.remove('active'));
                button.classList.add('active');
                document.getElementById(tab).classList.add('active');
            }
        });
    });

    walletAddressDisplay.addEventListener('click', () => {
        let newWalletAddress = prompt("Enter wallet address:", new URLSearchParams(window.location.search).get('walletAddress') || '');
        if (newWalletAddress !== null && newWalletAddress.trim() !== "") {
             const currentTab = new URLSearchParams(window.location.search).get('tab') || 'tokens';
             window.location.search = `?walletAddress=${newWalletAddress.trim()}&tab=${currentTab}`;
        } else if (newWalletAddress !== null) { // Empty input, clear address
             const currentTab = new URLSearchParams(window.location.search).get('tab') || 'tokens';
             window.location.search = `?tab=${currentTab}`;
        }
    });

    // Set active tab based on URL param on page load
    document.addEventListener('DOMContentLoaded', () => {
        const params = new URLSearchParams(window.location.search);
        const tab = params.get('tab') || 'tokens';
        
        tabButtons.forEach(btn => btn.classList.remove('active'));
        tabPanes.forEach(pane => pane.classList.remove('active'));

        const activeButton = document.querySelector(`.tab-button[data-tab="${tab}"]`);
        const activePane = document.getElementById(tab);

        if (activeButton) activeButton.classList.add('active');
        if (activePane) activePane.classList.add('active');
    });
</script>
</body>
</html>

Add basic styles to public/styles.css:

public/styles.css
: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:

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.

Fetch and Show Token Balances

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)
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
    // exclude_spam_tokens filters out known spam tokens
    const queryParams = `metadata=url,logo&exclude_spam_tokens`;

    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. There’s also exclude_spam_tokens to filter out common spam tokens.

The exclude_spam_tokens parameter provides a good baseline to filter out low-value and potentially malicious tokens, but you may want to implement custom filtering for your use case. Learn more in our Spam Filtering guide.

Next, modify your app.get('/') route handler in server.js to call getWalletBalances and pass the fetched tokens to your template:

server.js
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:

  1. Call getWalletBalances if a walletAddress is provided.
  2. Pass the retrieved balances to the wallet.ejs template.

The views/wallet.ejs file you created earlier is already set up to display these tokens. Restart your server with node server.js and refresh your browser, providing a walletAddress in the URL. For example: http://localhost:3001/?walletAddress=0xd8da6bf26964af9d7eed9e03e53415d37aa96045

You should now see the wallet populated with token balances, logos, prices for each token, and how much of that token the wallet holds.

Wallet displaying token balances (in wei) with logos and prices.

Format Balances

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)
async function getWalletBalances(walletAddress) {
    if (!walletAddress) return []; 

    const queryParams = `metadata=url,logo&exclude_spam_tokens`;
    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.

Calculate Total Portfolio Value

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)
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.

Conclusion

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.