src/lib/smartAccountClient.tsis the browser-facing piece. It configures the kit and exports anaccountthat the rest of the app uses for everything.src/routes/api/send/+server.tsis the server-facing piece. It’s a small proxy that adds our relayer API key to outgoing submissions, so the key never reaches the browser.
The smart account client
Theaccount export is the primary point of interaction between the dapp and the user’s passkey. Users sign up with account.createWallet(), log in with account.connectWallet(), sign and submit with account.signAndSubmit(), and log out with account.disconnect(). This account is a pretty tough workhorse.
We’re creating it in src/lib/smartAccountClient.ts so it’s available throughout the frontend. The #lib import alias is a SvelteKit thing, but the important part is that this file and its exports need to be reachable from anywhere in your app. How you arrange that in another framework is an exercise left to the reader.
src/lib/smartAccountClient.ts
accountWasmHashis the Wasm hash of the OpenZeppelin Smart Account contract code. Every user’s wallet is a fresh instance deployed from this executable. The hash is just the Sha256 of the compiled contract file, and it’s returned when that contract is installed on the network. The shared Testnet value comes pre-filled in.env.example, so there’s nothing for you to compile or deploy here.webauthnVerifierAddressis the contract address of the shared verifier that checkssecp256r1signatures on-chain. Also pre-filled.relayerUrlpoints at our own/api/sendroute rather than at OpenZeppelin directly. That’s the whole trick for keeping the API key server-side, and we’ll build that route below.storagedecides where a connected session lives.IndexedDBStoragemeans a session survives a page reload. As noted in the prerequisites, this is a per-browser cache sitting in front of the indexer, not a replacement for it.rpNameis the “relying party” name, which is the label your users will see in their authenticator’s prompt. Make it something they’ll recognize.
If you’re server-rendering any part of your app, note the
browser guard on storage. IndexedDB is a browser API, so handing the kit an IndexedDBStorage during SSR will fall over. Ye Olde Guestbook sets export const ssr = false in src/routes/+layout.ts and renders entirely on the client, but the guard costs nothing and saves a confusing error later.A few helpers
The kit covers authentication, but there are three small things the guestbook needs often enough to be worth wrapping. All of them live at the end of the same file. First, reading an XLM balance. There’s no SAC client to instantiate here: we simulate abalance call against the native Stellar Asset Contract and read the return value. Simulation is free and doesn’t touch the ledger, so this needs no signature and no fees.
src/lib/smartAccountClient.ts
cause. So we sniff for it:
src/lib/smartAccountClient.ts
connectWallet() does more than look a contract up and hand it back: before it connects, it verifies how the smart account was created, what code that account is currently running, and that the passkey the user just presented is a live signer on it. Any of those checks failing means the kit throws instead of connecting, and “please try again later” is poor advice for most of them, because trying again with the same passkey will fail in exactly the same way.
src/lib/smartAccountClient.ts
WalletAmbiguousErrormeans the credential resolved to more than one smart account, and discovery won’t guess which one you meant.WalletOwnershipErrormeans the passkey can’t prove control of a signer on the account it resolved to, which usually means that signer was removed.WalletProvenanceErrormeans the account’s creation history couldn’t be verified. This is the one case worth retrying, because a lagging or incomplete indexer looks the same from here as a wallet that genuinely doesn’t check out.WalletCodeNotAcceptedErrormeans the account is running contract code that isn’t on the kit’s accepted list, typically because it was upgraded somewhere else.
null for anything else matters as much as the four branches do. This helper only claims the errors it recognizes, and everything it doesn’t recognize falls through to the caller’s own generic message.
These checks are why a passkey that works perfectly well in one app can still be refused by another. The kit is deliberately fail-closed here, so it would rather decline to connect than hand your app a smart account it couldn’t fully vouch for.
Tracking the connected wallet
The kit emits events as wallets connect and disconnect, which is a tidy way to keep UI state in sync without every component reaching into the kit. In Ye Olde Guestbook that’s a small reactive class:src/lib/state/UserState.svelte.ts
$state rune is Svelte 5’s reactivity primitive, so this is the most framework-flavored file in the tutorial. The transferable idea is the shape of it: subscribe to account.events once, in one place, and let the rest of your components read a single piece of state. Swap $state for a React useState in a context provider, a Vue ref, or a plain observable, and the pattern holds.
The submission route
Now for the server side, where we need to be careful about leaking credentials. Smart Account Kit doesn’t talk to OpenZeppelin directly. Because we configuredrelayerUrl: '/api/send', it POSTs to our own origin instead, deliberately sending no credentials of its own (it’s running in the browser, so it has none worth sending). This route is what adds the API key and forwards the request on.
In SvelteKit, any file named +server.ts runs only on the server, and anything in a server directory (like src/lib/server) can’t be imported into client code at all. Those are the places your secrets are safe. Some of this is SvelteKit-specific, but every full-stack framework has an equivalent seam.
The kit sends one of two payload shapes, and Channels accepts both:
{ func, auth }is a Soroban host function plus its authorization entries. This is what user actions take, and it’s the sponsored path: Channels wraps the call in a channel-account transaction and pays the fees.{ xdr }is a fully signed transaction envelope.
src/routes/api/send/+server.ts
- The
origincheck is a first, cheap line of defense. Without it, anyone who findsyourdomain.com/api/sendcan submit their own transactions while you pick up the tab for the fees. - The
paramswrapper is what the Channels endpoint expects on the wire:{ "params": { ... } }, rather than the bare payload the kit sent us. You won’t see it in the OpenZeppelin Relayer guide, because those examples go through the relayer SDK, which adds the wrapper for you. We’re talking to the endpoint directly, so we add it ourselves. - Passing the relayer’s response through untouched keeps the route dumb, which is a feature. The kit treats a submission as successful only when the response status is ok and the body carries a top-level
success: true, which is exactly the{ success, data }envelope Channels already returns. Re-shaping the response here would only give us a new way to accidentally report a successful transaction as a failed one.