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.htmlThe 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.asentumexists, callconnect(). - 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,'&').replace(/</g,'<').replace(/>/g,'>');
}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 reverts —
send()throws with the revert message (e.g. "username already taken"). Catch it and show to the user. - Wallet locked —
window.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