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. A lot of the scaffolding a passkey dapp needs lives inside the kit, so there isn't much left for us to write.
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,
WalletAmbiguousError,
WalletCodeNotAcceptedError,
WalletOwnershipError,
WalletProvenanceError,
} from "smart-account-kit";
import { browser } from "$app/env";
import {
PUBLIC_STELLAR_RPC_URL,
PUBLIC_STELLAR_NETWORK_PASSPHRASE,
PUBLIC_ACCOUNT_WASM_HASH,
PUBLIC_WEBAUTHN_VERIFIER_ADDRESS,
PUBLIC_NATIVE_TOKEN_CONTRACT,
} from "$app/env/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 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 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 {
// The names of errors when the passkey prompt is declined
const dismissalNames = ["NotAllowedError", "AbortError"];
// Sometimes the error is itself the dismissal
const errName = (err as { name?: string } | null)?.name;
if (errName && dismissalNames.includes(errName)) {
return true;
}
// Sometimes, the error might be "wrapped" inside another error as a `cause`
const cause = (err as { cause?: unknown } | null)?.cause;
const causeName = (cause as { name?: string } | null)?.name;
if (causeName && dismissalNames.includes(causeName)) {
return true;
}
return false;
}
The signup and login flows run their errors past this first. A cancelled signup or login prompt returns without treating the dismissal as a failure.
Third, saying something useful when the kit refuses to connect at all. 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.
/**
* Turn one of the kit's fail-closed connection errors into something we can
* actually show a user.
*
* @param err - The error thrown by `connectWallet()`
* @returns A toast title and description, or `null` if this wasn't a
* connection-verification failure and the caller should fall back to its own
* generic message
*/
export function describeConnectionError(
err: unknown,
): { title: string; description: string } | null {
if (err instanceof WalletAmbiguousError) {
return {
title: "More than one wallet",
description:
"This passkey is registered to several smart accounts, and the guestbook has no way to ask which one you meant. Try the passkey you created here.",
};
}
if (err instanceof WalletOwnershipError) {
return {
title: "Passkey not recognized",
description:
"This passkey isn't a live signer on that smart account. Log in with the passkey you used when you signed up.",
};
}
if (err instanceof WalletProvenanceError) {
// This one is genuinely ambiguous. It covers a wallet whose creation
// history failed verification (permanent) and an indexer that is
// incomplete or lagging the network (transient), so the copy has to
// leave room for waiting it out.
return {
title: "Couldn't verify this wallet",
description:
"We could not confirm how this smart account was created. If you just signed up, the indexer may still be catching up, so give it a moment and try again.",
};
}
if (err instanceof WalletCodeNotAcceptedError) {
return {
title: "Unsupported wallet",
description:
"That smart account runs contract code this app does not accept, which usually means it was upgraded elsewhere.",
};
}
return null;
}
Each of those maps to a check the kit runs on your behalf:
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.
Returning 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:
import { account } from "#lib/smartAccountClient.js";
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 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.
You must never mix the two in one request, so the route validates that before forwarding.
import type { RequestHandler } from "./$types";
import { error } from "@sveltejs/kit";
import {
PRIVATE_RELAYER_BASE_URL,
PRIVATE_RELAYER_API_KEY,
} from "$app/env/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, "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, "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,
"request body must contain a transaction OR a function, not both",
);
}
if (!func && !xdr) {
error(400, "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 }),
});
// The kit counts a submission as successful only when the status is ok
// and the body carries a top-level `success: true`. Channels already
// answers with that `{ success, data }` envelope, so pass it through.
return Response.json(await res.json(), {
status: res.ok ? 200 : res.status,
});
} catch (err: unknown) {
console.error("[send]", err);
error(
502,
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 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.
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!