Dapp Frontend Walkthrough
So, we now have all the pieces in place, and we're ready to connect the dots.
Account type things
Since we've just gone through the smart account setup, let's begin there. We'll create the functions for signup, login, and the "profile menu" that drops down when a user is logged in (with buttons for viewing the wallet on a block explorer, sending a donation, requesting more Testnet funds, etc.).
We're using Svelte state to keep track of the user's smart account address, in the wallet object we built on the previous page. Your implementation may differ depending on your frontend, state management, and project design. Draw inspiration from the pattern rather than the exact code.
Connect Buttons setup
The ConnectButtons.svelte component shows either the Signup and Login buttons (when logged out) or the Settings popover (when logged in). On mount, it also tries to reconnect a returning user:
<script lang="ts">
import { wallet } from '$lib/state/UserState.svelte';
import { account } from '$lib/smartAccountClient';
import Settings from './Settings.svelte';
import Signup from './Signup.svelte';
import Login from './Login.svelte';
import { onMount } from 'svelte';
onMount(async () => {
try {
// The kit keeps its own session, so this restores a returning user
// without prompting them for their passkey again.
const restored = await account.connectWallet();
if (restored) {
console.log('[connected] contractAddress', wallet.contractAddress);
}
} catch (err: unknown) {
console.warn('[connect] silent reconnect failed:', err);
}
});
</script>
<div class="flex space-x-1 md:space-x-2">
{#if !wallet.contractAddress}
<Signup />
<Login />
{:else}
<Settings />
{/if}
</div>
Note that connectWallet() is called here with no arguments. That's deliberate: without prompt: true, the kit will restore an existing session if it finds one and otherwise do nothing at all. No passkey prompt fires on page load, which is exactly what you want. We never assign to wallet.contractAddress in this component either, because the kit emits walletConnected and our Wallet class is already listening.
Let's dig into each interaction.
User signup
For signup, we call account.createWallet(). Under the hood, Smart Account Kit:
- Runs the WebAuthn ceremony to create a new passkey on the user's device,
- Builds a deploy transaction for a fresh OpenZeppelin Smart Account using the passkey's public key as the initial signer,
- Submits that deploy transaction through our
/api/sendroute (because we passedautoSubmit: true), and - Tops the new account up with some Testnet XLM (because we passed
autoFund: true).
<script lang="ts">
import { account, userDismissedPasskey } from '$lib/smartAccountClient';
import { PUBLIC_NATIVE_TOKEN_CONTRACT } from '$env/static/public';
let username: string = $state('');
async function signup() {
try {
const { fundResult, submitResult } = await account.createWallet(
'Ye Olde Guestbook',
username,
{
// deploys the smart account through the relayer and
// connects once the deployment has landed
autoSubmit: true,
// tops up the new account from Friendbot so the user has
// some Testnet XLM to work with
autoFund: true,
// required when `autoFund: true`
nativeTokenContract: PUBLIC_NATIVE_TOKEN_CONTRACT,
},
);
// No wallet means there's nothing to log in to, so this one is fatal.
if (!submitResult?.success) {
throw submitResult?.error ?? new Error('failed to deploy smart account');
}
// An empty wallet is still a wallet. There's a "Fund Wallet" button
// in the settings menu if this part didn't take.
if (fundResult && !fundResult.success) {
console.warn('[fund]', fundResult.error);
}
} catch (err: unknown) {
// A dismissed passkey prompt isn't an error worth shouting about.
if (userDismissedPasskey(err)) return;
console.error('[signup]', err);
}
}
// ...omitted: the toasts, and the `isSigningUp` flag driving the spinner
</script>
The thing to take from that is createWallet reporting two outcomes which don't deserve equal treatment: a failed deploy leaves the user with nothing, while a failed top-up leaves them with a working (if empty) smart account. Treating those the same way would mean failing signup over play money.
userDismissedPasskey earns its keep for a similar reason. A user who opens the passkey prompt and thinks better of it has not encountered an error, and telling them "something went wrong" is a small lie that makes your app feel broken. Every passkey flow in this app checks for the dismissal first.
User login
For returning users, account.connectWallet({ prompt: true }) asks the browser's passkey picker to let the user choose a credential. Smart Account Kit takes the selected credential and resolves the smart account contract it belongs to.
<script lang="ts">
import { account, userDismissedPasskey } from '$lib/smartAccountClient';
async function login() {
try {
// `prompt: true` asks the user's authenticator to pick a passkey;
// SmartAccountKit uses the selected credential to look up the
// matching smart account contract via its IndexedDB index.
await account.connectWallet({ prompt: true });
} catch (err: unknown) {
if (userDismissedPasskey(err)) return;
console.error('[login]', err);
}
}
// ...omitted: the same toast handling as Signup
</script>
<button class="btn preset-tonal-primary" onclick={login}>Login</button>
This is the flow that used to need Mercury and a Zephyr program of your own. The credential-to-contract lookup still has to happen, it's just not your problem any more: the kit checks its local IndexedDB index first and falls back to a hosted indexer. Have a look back at the prerequisites if you skipped that part, because it's the one place where "I didn't configure it" is easy to mistake for "it isn't happening."
User logout
Logging out is a single call. account.disconnect() clears the kit's stored session and emits walletDisconnected, which our Wallet class picks up to null out the address, which flips ConnectButtons back to showing Signup and Login. No manual state juggling and no page reload.
async function logout() {
try {
await account.disconnect();
} catch (err: unknown) {
console.error("[logout]", err);
// ...omitted: the error toast
}
}
With those three flows our dapp is ready to onboard users.
The "profile menu"
When a user is logged in, the Settings popover shows their balance, their contract address, and buttons for funding, donating, and logging out. Three of those are worth a look: reading a balance (no signing at all), topping the wallet up, and sending a donation (the first user-signed transaction in the tutorial).
Reading the balance
This one's already done. We wrote getNativeBalance back on the setup page, and it simulates a balance call against the native Stellar Asset Contract. Simulation costs nothing and needs no signature, so reading a balance is about as cheap as an interaction gets.
let balance: string = $state("0");
async function getBalance() {
try {
balance = (await getNativeBalance(wallet.contractAddress!)).toString();
} catch (err: unknown) {
console.error("[balance]", err);
// ...omitted: the error toast
}
}
Remember that the value comes back in stroops, so there's a / 1e7 in the markup that renders it as XLM.
Funding the wallet
Signup already funded the account once, but Testnet play money has a way of running out. account.fundWallet() is the same machinery behind the autoFund option, exposed as a button.
// `account.fundWallet()` reports expected failures in the result rather
// than throwing, but `toaster.promise` depends on that rejection.
async function fundWallet() {
const result = await account.fundWallet(PUBLIC_NATIVE_TOKEN_CONTRACT);
if (!result.success) {
throw result.error;
}
return result;
}
That comment is doing more work than it looks like. Smart Account Kit reports expected failures (the relayer refused, the transaction failed on-chain) in a { success, error } result rather than by throwing, which is generally very pleasant. But toaster.promise wants a rejected promise to show its error state. So we translate between the two conventions, and this little if (!result.success) throw result.error shim shows up in a few places in the codebase for exactly that reason.
This is worth internalizing before you go hunting for a bug that isn't there: with the kit's submission methods, a try/catch alone will not catch a failed transaction. You have to check result.success. The catch block is for the unexpected stuff (a dismissed passkey, a network blip, a programming error).
Sending a donation
The hubris of soliciting donations on a guestbook is a matter for the maintainer's conscience. Mechanically, though, it's a lovely demonstration, because a donation is the first thing in this tutorial that moves real value and needs the user's passkey.
It's also a one-liner:
<script lang="ts">
import { account } from '$lib/smartAccountClient';
import { networks } from 'ye_olde_guestbook';
import { PUBLIC_NATIVE_TOKEN_CONTRACT } from '$env/static/public';
let donation: number | undefined = $state();
async function sendDonation() {
if (!donation) {
throw 'undefined donation amount';
}
// `account.transfer()` signs with the connected passkey and submits
// through the relayer in one step. Easy peasy!
const result = await account.transfer(
PUBLIC_NATIVE_TOKEN_CONTRACT,
networks.testnet.contractId,
donation,
);
if (!result.success) {
throw result.error;
}
console.log('[donate]', result);
}
</script>
account.transfer() takes the token contract, the recipient, and an amount in whole tokens (the kit handles the conversion to stroops for you), then builds the transfer, prompts the user's authenticator, signs the authorization entry, re-simulates with the real signature in place, and submits through our relayer route. That last re-simulation matters more than you'd think: a WebAuthn signature is substantially larger than the placeholder used in the first simulation, so the resource fees have to be recalculated before submission. The kit does it; you just need to know it's happening if you ever go building transactions by hand.
The recipient here is networks.testnet.contractId, which is the guestbook contract itself, pulled straight from our generated bindings. The from address is implicit: it's the connected smart account, and the SAC enforces that with the passkey-signed authorization entry.
Sign the guestbook
Now the main event: writing a guestbook entry. The page itself is an ordinary form, with inputs bound to messageTitle and messageText and a Sign button wired to the function below, so we'll skip the markup and go straight to where the contract call happens:
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { Api } from "@stellar/stellar-sdk/rpc";
import { account, rpc } from "$lib/smartAccountClient";
import { wallet } from "$lib/state/UserState.svelte";
import ye_olde_guestbook from "$lib/contracts/ye_olde_guestbook";
async function signGuestbook() {
try {
if (!wallet.contractAddress) {
throw "user missing contract address";
}
const at = await ye_olde_guestbook.write_message({
author: wallet.contractAddress,
title: messageTitle,
text: messageText,
});
const result = await account.signAndSubmit(at);
if (!result.success) {
throw result.error;
}
// The relayer reports a hash rather than the invocation's return
// value, so read the new message's id back from the network.
const response = await rpc.pollTransaction(result.hash);
if (
response.status !== Api.GetTransactionStatus.SUCCESS ||
!response.returnValue
) {
throw new Error(`Transaction ${result.hash} did not return a value`);
}
const messageId = response.returnValue.u32();
goto(resolve(`/read/${messageId}`));
} catch (err: unknown) {
console.error("[sign]", err);
}
// ...omitted: success and error toasts, and the isLoading flag
}
The write_message function comes from our generated contract bindings, so invoking a Soroban function looks like any other typed TypeScript call. account.signAndSubmit() then does the passkey ceremony, the re-simulation, and the submission.
Here's a gotcha that will bite you the first time, so let's be explicit about it. Our contract's write_message function returns the new message's ID, and you might reasonably expect to find it on the assembled transaction after submitting. You won't. When a transaction goes out through a relayer, what comes back is a transaction hash, not the invocation's return value, because the relayer submitted it from a channel account and we only ever saw the receipt.
So to get the message ID, we take the hash, poll the network for that transaction with rpc.pollTransaction(), and pull the returnValue off the result. It's an extra round trip, and it's the price of not making your users think about fees.
With the ID in hand, we redirect the user to the page for their shiny new entry.
Read guestbook entries
Read a single entry
Reading doesn't involve passkeys, relayers, or signatures at all, which makes it a nice palate cleanser. We use a server-side load function so the query runs on the server (and could benefit from caching if we were using a paid RPC). The [id] in the filename is a path parameter.
import { error } from "@sveltejs/kit";
import guestbook from "$lib/contracts/ye_olde_guestbook";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
try {
const { result } = await guestbook.read_message({
message_id: parseInt(params.id),
});
return {
id: params.id,
message: result.unwrap(),
};
} catch (err: unknown) {
console.error(err);
error(500, {
message:
"Sorry, something went wrong. Most likely, the message you're looking for doesn't exist.",
});
}
};
read_message is a read-only function, so the bindings client simulates it and we can use the result directly. No signing and no submission needed. The result.unwrap() call is there because our contract function returns a Rust Result, which the bindings faithfully carry across into TypeScript.
The page component then hands that message straight to a shared GuestbookMessage component for display, which is where we're headed next.
Read all entries
For the "list all entries" page, we keep the query logic server-side in src/lib/server/getLedgerEntries.ts. Rather than invoking the contract once per message, it reads the ledger directly: one rpc.getLedgerEntries() call against the contract's instance storage to find the message count, then a single batched call for every message ID from there.
export async function getAllMessages(): Promise<MessageWithIndex[]> {
const totalCount = await getMessageCount();
const ledgerKeysArray = [];
// if the total count is only 1, then we have just the welcome message
if (totalCount < 2) {
return [];
}
for (let messageId = 2; messageId <= totalCount; messageId++) {
ledgerKeysArray.push(buildMessageLedgerKey(messageId));
}
const result = await rpc.getLedgerEntries(...ledgerKeysArray);
const messages = result.entries.map((message) => {
const key = scValToNative(message.val.contractData().key())[1]; // scVal of the key is ['Message', 2]
const val = scValToNative(
message.val.contractData().val(),
) as MessageWithIndex;
val.id = key;
return val;
});
return messages;
}
This is a genuinely useful trick to have in your pocket. Because we know how the contract lays out its storage keys (a Message(u32) variant of the DataKey enum, remember), we can construct those ledger keys ourselves and ask RPC for all of them in one request. See the full implementation for the key-building helper and the message-count read.
Edit a guestbook entry
Inside the GuestbookMessage component, the logged-in author of a message can edit it. When they submit the edit, we call edit_message the same way we called write_message: build, sign, submit, done.
import ye_olde_guestbook from "$lib/contracts/ye_olde_guestbook";
import { account } from "$lib/smartAccountClient";
import { wallet } from "$lib/state/UserState.svelte";
const submitEdit = async () => {
try {
if (!wallet.contractAddress) {
throw "user missing contract address";
}
const at = await ye_olde_guestbook.edit_message({
message_id: messageId,
title: messageTitle,
text: messageText,
});
const result = await account.signAndSubmit(at);
if (!result.success) {
throw result.error;
}
} catch (err: unknown) {
console.error("[edit]", err);
}
// ...omitted: the toasts, and the isEditing/isLoading flags
};
Notice we don't pass an author argument here, and there's no pollTransaction either. We don't need the author because the contract's edit_message function reads it from its own storage and requires authentication from that specific smart account, which means the original author is the only one who can modify their entry. Not even the guestbook's host can change it. And we don't need to poll because edit_message doesn't return anything we care about, so the hash is a perfectly good receipt.
That's the full flow. With account.signAndSubmit(at) standing in for "sign this with my passkey and get it on-chain without charging my user," wiring a Soroban dapp up to a smart wallet gets about as light as it's ever been.
Way to go! Now go put something in the guestbook.