Build on Asentum
Build a Messaging System
Multi-contract tutorial · Estimated read time: 20 minutes
What we're building
An on-chain messaging system where users register usernames, send messages to each other, and read their inbox — all through smart contracts. We'll build two contracts:
- UserRegistry — maps wallet addresses to usernames (and back). A reusable identity layer.
- DirectMessages — stores, indexes, and retrieves messages between addresses.
This tutorial shows modular contract design on Asentum. Each contract handles one concern. The frontend (covered in the next guide) ties them together via the SDK.
Two contracts, one app
┌─────────────────┐ ┌─────────────────────┐
│ UserRegistry │ │ DirectMessages │
│ │ │ │
│ register() │ │ send() │
│ getUsername() │◄────│ getInbox() │
│ getAddress() │ SDK │ getOutbox() │
│ getUserCount() │reads│ getConversation() │
└─────────────────┘ └─────────────────────┘
▲ ▲
│ Frontend │
└────── orchestrates ────┘In this tutorial the contracts don't call each other directly — the frontend reads from both and orchestrates the flow. (They could use E(addr).method() for on-chain cross-calls, but frontend orchestration is a useful pattern to learn.) When a user sends a message to "alice", the frontend:
- Calls
registry.view('getAddress', ['alice'])to resolve the username. - Calls
messages.send('send', [resolvedAddress, content])to deliver the message.
This is how many production dApps compose contracts — atomic stores orchestrated by application logic. It's clean, testable, and each contract is independently deployable and reusable. For on-chain composition, Asentum also supports direct cross-contract calls via E() with a VM-enforced reentrancy guard — see Writing Contracts.
Contract 1: UserRegistry
// UserRegistry — bidirectional address ↔ username mapping.
function init() {
assert(!storage.has('initialized'), 'already initialized');
storage.set('initialized', 'true');
storage.set('userCount', '0');
}
function register(username) {
const sender = msg.sender;
const name = String(username).toLowerCase().trim();
// Validation
assert(name.length >= 3, 'username must be at least 3 characters');
assert(name.length <= 20, 'username must be at most 20 characters');
assert(/^[a-z0-9_]+$/.test(name), 'username: only a-z, 0-9, underscore');
assert(!storage.has('user:' + sender), 'address already registered');
assert(!storage.has('name:' + name), 'username already taken');
// Store bidirectional mapping
storage.set('user:' + sender, name);
storage.set('name:' + name, sender);
// Increment counter
const count = BigInt(storage.get('userCount') || '0') + 1n;
storage.set('userCount', String(count));
emit('Registered', { address: sender, username: name });
}
// ─── View methods ───
function getUsername(address) { return storage.get('user:' + address) || null; }
function getAddress(username) { return storage.get('name:' + String(username).toLowerCase()) || null; }
function getUserCount() { return storage.get('userCount') || '0'; }
function isRegistered(address) { return storage.has('user:' + address); }Key patterns:
- Bidirectional mapping —
user:ADDRESS → usernameandname:USERNAME → address. Two keys per registration, but O(1) lookup in both directions. - Guard clauses —
assert(!storage.has(...))prevents double-registration. A wallet can register once. A username can be claimed once. - Input normalization — lowercase + trim so "Alice" and "alice" resolve to the same account.
- Regex validation — only alphanumeric + underscore. No special chars, no spaces, no emoji.
Contract 2: DirectMessages
// DirectMessages — stores and indexes messages between addresses.
function init() {
assert(!storage.has('initialized'), 'already initialized');
storage.set('initialized', 'true');
storage.set('messageCount', '0');
}
function send(to, content) {
const text = String(content).trim();
assert(text.length > 0, 'message cannot be empty');
assert(text.length <= 500, 'message too long (max 500 chars)');
assert(to !== msg.sender, 'cannot message yourself');
// Create the message object
const id = storage.get('messageCount') || '0';
const message = JSON.stringify({
id,
from: msg.sender,
to,
content: text,
timestamp: String(chain.blockTimestamp),
block: String(chain.blockNumber),
});
// Store the message by ID
storage.set('msg:' + id, message);
// Append ID to recipient's inbox
const inKey = 'in:' + to;
const inbox = JSON.parse(storage.get(inKey) || '[]');
inbox.push(id);
storage.set(inKey, JSON.stringify(inbox));
// Append ID to sender's outbox
const outKey = 'out:' + msg.sender;
const outbox = JSON.parse(storage.get(outKey) || '[]');
outbox.push(id);
storage.set(outKey, JSON.stringify(outbox));
// Increment global counter
storage.set('messageCount', String(BigInt(id) + 1n));
emit('MessageSent', { id, from: msg.sender, to });
}
// ─── View methods ───
function getMessage(id) {
const raw = storage.get('msg:' + id);
return raw ? JSON.parse(raw) : null;
}
function getInbox(address) {
const ids = JSON.parse(storage.get('in:' + address) || '[]');
return ids.map(id => JSON.parse(storage.get('msg:' + id) || 'null')).filter(Boolean);
}
function getOutbox(address) {
const ids = JSON.parse(storage.get('out:' + address) || '[]');
return ids.map(id => JSON.parse(storage.get('msg:' + id) || 'null')).filter(Boolean);
}
function getConversation(addrA, addrB) {
// Merge inbox + outbox for both participants, dedupe, sort by block.
const aIn = JSON.parse(storage.get('in:' + addrA) || '[]');
const aOut = JSON.parse(storage.get('out:' + addrA) || '[]');
const allIds = [...new Set([...aIn, ...aOut])];
return allIds
.map(id => JSON.parse(storage.get('msg:' + id) || 'null'))
.filter(m => m && ((m.from === addrA && m.to === addrB) || (m.from === addrB && m.to === addrA)))
.sort((a, b) => Number(BigInt(a.block) - BigInt(b.block)));
}
function getMessageCount() { return storage.get('messageCount') || '0'; }Storage patterns for lists
Asentum contracts don't have native arrays. The DirectMessages contract uses a JSON-array-in-a-key pattern:
// Reading the list
const inbox = JSON.parse(storage.get('in:' + address) || '[]');
// Appending to the list
inbox.push(newMessageId);
storage.set('in:' + address, JSON.stringify(inbox));Tradeoff: this is O(n) on the list size per write (re-serialize the whole array). For a messaging app with thousands of messages per user, you'd want a linked-list or pagination pattern. For a tutorial and low-volume use, the JSON array is fine and readable.
The message itself is stored separately at msg:ID. The inbox/outbox arrays store only IDs — lightweight references. View methods resolve the IDs into full message objects at read time.
Testing both contracts
import { test, expect } from 'vitest';
import { createVm } from '@asentum/vm';
import { readFileSync } from 'node:fs';
const registrySource = readFileSync('./contracts/user-registry.js', 'utf8');
const messagesSource = readFileSync('./contracts/direct-messages.js', 'utf8');
test('register + send + read inbox', async () => {
const vm = createVm();
// Deploy both
const registry = await vm.deploy(registrySource);
const messages = await vm.deploy(messagesSource);
await registry.call('init');
await messages.call('init');
// Register two users
await registry.callAs('0xAlice', 'register', ['alice']);
await registry.callAs('0xBob', 'register', ['bob']);
// Resolve username → address
const bobAddr = await registry.view('getAddress', ['bob']);
expect(bobAddr).toBe('0xBob');
// Alice sends Bob a message
await messages.callAs('0xAlice', 'send', ['0xBob', 'Hey Bob!']);
// Bob's inbox has 1 message
const inbox = await messages.view('getInbox', ['0xBob']);
expect(inbox).toHaveLength(1);
expect(inbox[0].content).toBe('Hey Bob!');
expect(inbox[0].from).toBe('0xAlice');
// Conversation view
const convo = await messages.view('getConversation', ['0xAlice', '0xBob']);
expect(convo).toHaveLength(1);
});
test('username validation', async () => {
const vm = createVm();
const registry = await vm.deploy(registrySource);
await registry.call('init');
await expect(registry.callAs('0xA', 'register', ['ab']))
.rejects.toThrow('at least 3');
await expect(registry.callAs('0xA', 'register', ['hello world']))
.rejects.toThrow('only a-z');
});Deploying the pair
import { AsentumClient, AsentumWallet } from '@asentum/sdk';
import { readFileSync } from 'node:fs';
const client = new AsentumClient('https://testnet.asentum.com');
const wallet = AsentumWallet.create(client);
await wallet.requestFaucet();
// Deploy UserRegistry
const registrySrc = readFileSync('./contracts/user-registry.js', 'utf8');
const registry = await wallet.deploy(registrySrc);
await (await registry.send('init')).wait();
console.log('registry:', registry.address);
// Deploy DirectMessages
const messagesSrc = readFileSync('./contracts/direct-messages.js', 'utf8');
const messages = await wallet.deploy(messagesSrc);
await (await messages.send('init')).wait();
console.log('messages:', messages.address);
// Save these addresses — the frontend needs them.
console.log(JSON.stringify({
registry: registry.address,
messages: messages.address,
}));Save the two contract addresses. The frontend guide uses them to connect the SDK to both contracts.
Using them together
import { AsentumClient, AsentumContract } from '@asentum/sdk';
const client = new AsentumClient('https://testnet.asentum.com');
const registry = new AsentumContract(client, REGISTRY_ADDRESS);
const messages = new AsentumContract(client, MESSAGES_ADDRESS);
// Register a username (needs a wallet for signing)
const withSigner = registry.connect(wallet);
await (await withSigner.send('register', ['alice'])).wait();
// Resolve a username and send a message
const bobAddr = await registry.view('getAddress', ['bob']);
if (!bobAddr) throw new Error('user "bob" not found');
const msgContract = messages.connect(wallet);
await (await msgContract.send('send', [bobAddr, 'Hello from Alice!'])).wait();
// Read inbox
const inbox = await messages.view('getInbox', [wallet.address]);
console.log('inbox:', inbox);This is the pattern the frontend uses: resolve names via one contract, act via another. The SDK makes both calls look identical — the only difference is which contract address you point at.
Next in the series