Skip to main content
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:
src/lib/components/ConnectButtons/ConnectButtons.svelte
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:
  1. Runs the WebAuthn ceremony to create a new passkey on the user’s device,
  2. Builds a deploy transaction for a fresh OpenZeppelin Smart Account using the passkey’s public key as the initial signer,
  3. Submits that deploy transaction through our /api/send route (because we passed autoSubmit: true), and
  4. Tops the new account up with some Testnet XLM (because we passed autoFund: true).
src/lib/components/ConnectButtons/Signup.svelte
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. The signup and login flows check 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.
src/lib/components/ConnectButtons/Login.svelte
There’s more happening in that one call than it lets on. The credential-to-contract lookup has to take place somewhere, and the kit handles it: it checks its local IndexedDB index first, then 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.” The lookup is only half of it, though. Before the kit connects, it also verifies how that smart account was created, what contract code it’s running now, and that the passkey the user just presented is a live signer on it. A failed check means no connection at all, which is why the catch block runs the error past describeConnectionError from the setup page before falling back to a generic message. “Something went wrong, please try again later” is actively misleading for a passkey that is never going to work on this account.

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.
src/lib/components/ConnectButtons/Settings.svelte
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.
src/lib/components/ConnectButtons/Settings.svelte
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.
src/lib/components/ConnectButtons/Settings.svelte
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:
src/lib/components/ConnectButtons/DonateButton.svelte
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:
src/routes/sign/+page.svelte
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.
src/routes/read/[id]/+page.server.ts
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 batched getLedgerEntries calls of at most 200 keys (the Stellar RPC cap).
src/lib/server/getLedgerEntries.ts
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 them in batches of at most 200. 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.
src/lib/components/GuestbookMessage.svelte
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.