Setup Passkeys
With the relayer key in place, we're ready to put Smart Account Kit to work and get our users connected. This takes two files:
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.
That's genuinely it. Compared to the Launchtube-and-Mercury era, a lot of scaffolding has collapsed into the kit itself.
The smart account client
The account 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.
import { Server, Api } from "@stellar/stellar-sdk/rpc";
import {
Account,
Address,
BASE_FEE,
Contract,
TransactionBuilder,
scValToNative,
} from "@stellar/stellar-sdk";
import { SmartAccountKit, IndexedDBStorage } from "smart-account-kit";
import { browser } from "$app/environment";
import {
PUBLIC_STELLAR_RPC_URL,
PUBLIC_STELLAR_NETWORK_PASSPHRASE,
PUBLIC_ACCOUNT_WASM_HASH,
PUBLIC_WEBAUTHN_VERIFIER_ADDRESS,
PUBLIC_NATIVE_TOKEN_CONTRACT,
} from "$env/static/public";
/**
* A configured Stellar RPC server instance used to interact with the network.
*/
export const rpc = new Server(PUBLIC_STELLAR_RPC_URL);
/**
* The smart account client. Wallets are OpenZeppelin smart account contracts,
* authenticated with WebAuthn passkeys.
*/
export const account = new SmartAccountKit({
rpcUrl: PUBLIC_STELLAR_RPC_URL,
networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE,
accountWasmHash: PUBLIC_ACCOUNT_WASM_HASH,
webauthnVerifierAddress: PUBLIC_WEBAUTHN_VERIFIER_ADDRESS,
// Transactions are POSTed to our own `/api/send` route, which forwards them
// on to the OpenZeppelin Relayer Channels service. The Channels API key
// stays server-side.
relayerUrl: "/api/send",
// IndexedDB isn't available while server-rendering, but the kit is only
// ever driven from the browser anyway.
storage: browser ? new IndexedDBStorage() : undefined,
// the "relying-party" name will be displayed in the passkey prompt from the
// user's authenticator
rpName: "Ye Olde Guestbook",
timeoutInSeconds: 30,
});
Worth calling out a few of those options:
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 couple of helpers
The kit covers authentication, but there are two small things the guestbook needs often enough to be worth wrapping. Both live at the end of the same file.
First, reading an XLM balance. There's no SAC client to instantiate here: we simulate a balance 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.
/**
* Read an address's native XLM balance by simulating a `balance` call against
* the native Stellar Asset Contract.
*
* @param address - The address whose balance to read
* @returns The balance, in stroops
*/
export async function getNativeBalance(address: string): Promise<bigint> {
const transaction = new TransactionBuilder(
// We use a dummy account for simulation-only transactions.
new Account(
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
"0",
),
{
fee: BASE_FEE,
networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE,
},
)
.addOperation(
new Contract(PUBLIC_NATIVE_TOKEN_CONTRACT).call(
"balance",
new Address(address).toScVal(),
),
)
.setTimeout(30)
.build();
const simulation = await rpc.simulateTransaction(transaction);
if (!Api.isSimulationSuccess(simulation) || !simulation.result) {
throw new Error("Unable to read balance");
}
return scValToNative(simulation.result.retval) as bigint;
}
Second, a bit of passkey ergonomics that took us longer to get right than we'd like to admit. When a user opens the passkey prompt and then dismisses it, that's not an error you want to shout about. Trouble is, "the user changed their mind" arrives as different exception names depending on the browser, the platform, and whether it came wrapped in a cause. So we sniff for it:
/**
* Figure out if authenticating with a passkey was simply the user
* dismissing the prompt. This can present itself in a few different ways,
* depending on a user's computer/browser/etc.
*/
export function userDismissedPasskey(err: unknown): boolean {
const nameOf = (e: unknown) => (e as { name?: string } | null)?.name;
const name =
nameOf(err) ?? nameOf((err as { cause?: unknown } | null)?.cause);
return name === "NotAllowedError" || name === "AbortError";
}
Every passkey flow in the app runs its errors past this first, so a cancelled prompt shows a gentle "Cancelled" toast instead of a scary red "something went wrong."
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:
import { account } from "$lib/smartAccountClient";
class Wallet {
contractAddress: string | null = $state(null);
constructor() {
account.events.on(
"walletConnected",
({ contractId }) => (this.contractAddress = contractId),
);
account.events.on(
"walletDisconnected",
() => (this.contractAddress = null),
);
}
}
export const wallet = new Wallet();
The $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 configured relayerUrl: '/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 under $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.
You must never mix the two in one request, so the route validates that before forwarding.
import type { RequestHandler } from "./$types";
import { error, json } from "@sveltejs/kit";
import {
PRIVATE_RELAYER_BASE_URL,
PRIVATE_RELAYER_API_KEY,
} from "$env/static/private";
/**
* The smart account kit POSTs either `{ func, auth }` (a smart contract
* invocation) or `{ xdr }` (a fully signed envelope) to this endpoint. It
* deliberately sends no credentials, because it runs in the browser. This route
* adds the OpenZeppelin Relayer Channels API key and forwards the request on,
* so the key never leaves the server.
*/
export const POST: RequestHandler = async ({ url, request, fetch }) => {
// ensure requests are coming from our own frontend
if (request.headers.get("origin") !== url.origin) {
error(403, { message: "hostname mismatch" });
}
// parse the request body and get the transaction details
const body = await request.json().catch(() => null);
if (!body || typeof body !== "object") {
error(400, { message: "request body must be a JSON object" });
}
const { func, auth, xdr }: { func?: string; auth?: string[]; xdr?: string } =
body;
// Channels takes either a signed transaction envelope, or a host function
// plus its auth entries. But, you must never mix the two shapes!
if (func && xdr) {
error(400, {
message:
"request body must contain a transaction OR a function, not both",
});
}
if (!func && !xdr) {
error(400, {
message: "request body must contain either a function or a transaction",
});
}
const params = func ? { func, auth } : { xdr };
try {
const res = await fetch(`${PRIVATE_RELAYER_BASE_URL}/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PRIVATE_RELAYER_API_KEY}`,
},
body: JSON.stringify({ params }),
});
// Pass the relayer's response through untouched. The kit understands both
// the `{ success, data }` envelope and a bare transaction result.
return json(await res.json(), { status: res.ok ? 200 : res.status });
} catch (err: unknown) {
console.error("[send]", err);
error(502, {
message: err instanceof Error ? err.message : "relayer submission failed",
});
}
};
A few things this route is doing on purpose:
- 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 already understands both the
{ success, data }envelope Channels returns and a bare transaction result, so re-shaping the response here would only give us something new to keep in sync.
An origin header is trivially forged by anything that isn't a browser, so please don't mistake the check above for real authorization. Before you put something like this on Mainnet you'll want actual rate limiting, a way to bound which transactions you're willing to sponsor, and some monitoring on your relayer credits. Otherwise a bad actor can happily drain them.
Implementing all of that is outside the scope of this tutorial, but it's very much inside the scope of shipping to real users.
Still with us?! Incredible! You're a rock star! And, you're ready to get into the interactions with the smart contract! See you on the next page!