Asentum

Build on Asentum

Build a Frontend with the SDK

Full-stack tutorial · Estimated read time: 20 minutes

What we're building

A working messaging app in the browser — register a username, send messages, read your inbox — powered by the two contracts from the previous guide. Vanilla HTML + JS. No React, no build step. Just a single index.html and the @asentum/sdk.

By the end you'll understand how to read from contracts (free), write to contracts (signed transactions), orchestrate multiple contracts from a frontend, and handle the transaction lifecycle.

Project setup

Create a folder with one file. No npm, no bundler:

mkdir asentum-chat && cd asentum-chat
touch index.html

The SDK is loaded via a script tag. If you prefer npm, npm i @asentum/sdk and import it as an ES module instead.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Asentum Chat</title>
  <style>
    body { font-family: system-ui; background: #000; color: #ccc; max-width: 600px; margin: 40px auto; }
    input, button { font-size: 14px; padding: 8px 12px; }
    input { background: #111; border: 1px solid #333; color: #fff; width: 100%; margin-bottom: 8px; }
    button { background: #222; border: 1px solid #444; color: #fff; cursor: pointer; }
    button:hover { border-color: #888; }
    .msg { padding: 8px; border-bottom: 1px solid #222; }
    .msg .from { color: #A6A6FF; font-size: 12px; }
    .msg .text { margin-top: 4px; }
    .hidden { display: none; }
    #status { color: #26CC6B; font-size: 12px; margin: 8px 0; }
    #error  { color: #e55; font-size: 12px; margin: 8px 0; }
  </style>
</head>
<body>
  <h1>Asentum Chat</h1>
  <div id="status"></div>
  <div id="error"></div>

  <!-- Step 1: Connect -->
  <div id="connect-section">
    <button id="btn-connect">Connect Wallet</button>
  </div>

  <!-- Step 2: Register -->
  <div id="register-section" class="hidden">
    <h3>Pick a username</h3>
    <input id="username-input" placeholder="e.g. alice (3-20 chars, a-z 0-9 _)" />
    <button id="btn-register">Register</button>
  </div>

  <!-- Step 3: Send -->
  <div id="compose-section" class="hidden">
    <h3>Send a message</h3>
    <input id="to-input" placeholder="Recipient username" />
    <input id="msg-input" placeholder="Message (max 500 chars)" />
    <button id="btn-send">Send</button>
  </div>

  <!-- Step 4: Inbox -->
  <div id="inbox-section" class="hidden">
    <h3>Inbox</h3>
    <div id="inbox"></div>
  </div>

  <script type="module">
    // ─── SDK + contract setup ───
    // Replace with your deployed contract addresses from the messaging tutorial.
    const REGISTRY_ADDR = 'YOUR_REGISTRY_ADDRESS';
    const MESSAGES_ADDR = 'YOUR_MESSAGES_ADDRESS';
    const RPC = 'https://testnet.asentum.com';

    // ... (continued in sections below)
  </script>
</body>
</html>

Connecting a wallet

The app needs a wallet to sign transactions. Two paths:

  • Chrome extension — if window.asentum exists, call connect().
  • In-browser wallet — create a throwaway wallet with AsentumWallet.create() and fund it from the faucet. Good for testing.
import { AsentumClient, AsentumWallet, AsentumContract } from '@asentum/sdk';

const client = new AsentumClient(RPC);
let wallet;

document.getElementById('btn-connect').onclick = async () => {
  try {
    if (window.asentum) {
      // Use the Chrome extension
      await window.asentum.connect();
      const addr = await window.asentum.getAddress();
      status('Connected via extension: ' + addr);
    } else {
      // Create a throwaway in-browser wallet
      wallet = AsentumWallet.create(client);
      await wallet.requestFaucet();
      status('Created wallet: ' + wallet.address + ' (funded from faucet)');
    }
    show('register-section');
  } catch (err) {
    error(err.message);
  }
};

function status(msg) { document.getElementById('status').textContent = msg; }
function error(msg)  { document.getElementById('error').textContent = msg; }
function show(id)    { document.getElementById(id).classList.remove('hidden'); }

Wiring up the contracts

// Read-only handles (free view calls, no wallet needed)
const registry = new AsentumContract(client, REGISTRY_ADDR);
const messages = new AsentumContract(client, MESSAGES_ADDR);

// To send transactions, connect the wallet:
function registryWrite() { return registry.connect(wallet); }
function messagesWrite() { return messages.connect(wallet); }

Pattern: create a read-only contract handle first (no signer). Connect a wallet only when you need to write. View calls are always free — use them liberally for reads.

Registering a user

document.getElementById('btn-register').onclick = async () => {
  const username = document.getElementById('username-input').value.trim();
  if (!username) return error('Enter a username');

  try {
    status('Registering "' + username + '"...');
    const tx = await registryWrite().send('register', [username]);
    await tx.wait();

    status('Registered as "' + username + '"! You can send messages now.');
    show('compose-section');
    show('inbox-section');
    loadInbox();
  } catch (err) {
    error('Registration failed: ' + err.message);
  }
};

send() returns a transaction with a .wait() method. wait() resolves when the transaction is finalized — with 5-second blocks, that's near-instant.

Sending a message

This is the multi-contract orchestration moment. The frontend resolves the username via one contract, then sends the message via another:

document.getElementById('btn-send').onclick = async () => {
  const toName  = document.getElementById('to-input').value.trim();
  const content = document.getElementById('msg-input').value.trim();
  if (!toName || !content) return error('Fill in both fields');

  try {
    // Step 1: Resolve username → address (free view call)
    status('Resolving "' + toName + '"...');
    const toAddr = await registry.view('getAddress', [toName]);
    if (!toAddr) return error('User "' + toName + '" not found');

    // Step 2: Send the message (signed transaction)
    status('Sending message...');
    const tx = await messagesWrite().send('send', [toAddr, content]);
    await tx.wait();

    status('Message sent to ' + toName + '!');
    document.getElementById('msg-input').value = '';
    loadInbox(); // refresh
  } catch (err) {
    error('Send failed: ' + err.message);
  }
};

Notice: step 1 is free (view call, no gas, instant). Step 2 costs gas (signed, needs wallet, takes a block). The user only pays for the write.

Reading the inbox

async function loadInbox() {
  const addr = wallet?.address;
  if (!addr) return;

  const inbox = await messages.view('getInbox', [addr]);
  const el = document.getElementById('inbox');

  if (!inbox || inbox.length === 0) {
    el.innerHTML = '<p style="color:#555">No messages yet.</p>';
    return;
  }

  // Resolve sender addresses to usernames (batch of free view calls)
  const html = [];
  for (const msg of inbox.reverse()) {
    const senderName = await registry.view('getUsername', [msg.from]);
    html.push(
      '<div class="msg">' +
        '<div class="from">' + (senderName || msg.from.slice(0, 10) + '…') + '</div>' +
        '<div class="text">' + escapeHtml(msg.content) + '</div>' +
      '</div>'
    );
  }
  el.innerHTML = html.join('');
}

function escapeHtml(s) {
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

Every call here — getInbox and each getUsername — is a free view call. No gas, no approval, instant. This is the Asentum reading pattern: reads are always free.

Conversation view

async function loadConversation(otherAddr) {
  const myAddr = wallet.address;
  const convo = await messages.view('getConversation', [myAddr, otherAddr]);

  // convo is sorted by block number — oldest first
  for (const msg of convo) {
    const isMe = msg.from === myAddr;
    console.log((isMe ? 'You' : msg.from.slice(0,8)) + ': ' + msg.content);
  }
}

The getConversation method on the contract handles the merge + sort + filter. The frontend just renders the result.

Live updates via polling

// Poll for new messages every 5 seconds.
// (WebSocket subscriptions are on the Asentum roadmap — until then, polling works.)
setInterval(loadInbox, 5000);

Simple, effective, honest. When eth_subscribe ships you'll be able to listen for MessageSent events in real time. For now, 5-second polling is indistinguishable from "real-time" for a chat app on a 5-second-block chain.

Error handling

  • Contract revertssend() throws with the revert message (e.g. "username already taken"). Catch it and show to the user.
  • Wallet lockedwindow.asentum.isUnlocked() returns false. Prompt the user to unlock their extension.
  • Insufficient gas — the faucet gives test ASE for free. If a throwaway wallet runs out, call wallet.requestFaucet() again.
  • Network down — view calls and sends will throw with a fetch error. Wrap in try/catch and show a "testnet unreachable" message.

Full source

The complete index.html combines all the snippets above into a single working file. Deploy both contracts from the messaging tutorial, paste their addresses into the constants at the top of the script, open the file in a browser, and you have a working on-chain chat.

What you just built:

  • A wallet connection flow (extension or in-browser)
  • Multi-contract orchestration (registry for names, messages for chat)
  • Free reads via view calls (no gas, instant)
  • Signed writes via send calls (gas, approval, finality)
  • Live-updating inbox via polling
  • Error handling for reverts, locked wallets, and network issues

All in vanilla JS. No React, no build step, no framework. The same patterns scale to any frontend — Next.js, Svelte, mobile, whatever speaks HTTP to the RPC.

More from this series