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 all the passkeys setup, let's begin there. We'll create the functions that will be used to create the user's smart wallet, login with their smart wallet, and the logout functionality. We'll also add a "profile menu" that can drop down when a user is logged in and give them options for viewing their smart wallet on a block explorer, sending one of those all-important donations to our guestbook, requesting more (Testnet) funds, etc.
We're using some pieces of Svelte state to keep the value of the user's smart wallet contract address as well as the public key of their passkey. Your implementation of keeping this state may differ depending on your chosen frontend, state management, and project design. Hopefully, in any situation, you can draw inspiration from the way we've done it for this tutorial.
Connect Buttons Setup
We have a component in $lib/components/connectButtons.svelte
that houses all the signup, login, and logout functionality. This gets put into the header component, and is available throughout the entirety of the dapp. The basic premise of this component is that we have a collection of buttons, as well as the corresponding functions that should take place when the button is clicked.
The buttons themselves are simple enough:
<div class="flex space-x-1 md:space-x-2">
<button class="btn variant-filled-primary" onclick="{signup}">Signup</button>
<button class="btn variant-soft-primary" onclick="{login}">Login</button>
<button class="btn variant-soft-error" onclick="{logout}">Logout</button>
</div>
If you look at the source code of this component, you will see that we do quite a bit more state-checking surrounding the display of the buttons. This makes it so a "login" button doesn't display when a user is already logged in, for example. For the purpose of this tutorial, though, we'll focus on the functions themselves, rather than the HTML of the buttons.
Let's begin with the Signup function.
User signup
In order to signup our user, we'll make use of the account
instance of the PasskeyKit
class from our $lib/passkeyClient.ts
file. The account
instance has a function called createWallet
that will do most of the heavy lifting for us, we only need to make sure we call the function properly.
We do a little bit of error checking here, but not much. In practical applications, you would probably want to dive into the cause of any errors here, and ensure they are mitigated before telling a user to try again.
import { account, send, fundContract } from "$lib/passkeyClient";
import { keyId } from "$lib/stores/keyId";
import { contractId } from "$lib/stores/contractId";
async function signup() {
console.log("signing up");
try {
// The createWallet function takes two strings, an app name and a user name.
// It returns the public key of the passkey, a contract address which will
// be the user's wallet, and a built transaction (ready to submit) to create
// the smart wallet on-chain.
const {
keyId_base64,
contractId: cid,
built,
} = await account.createWallet("Ye Olde Guestbook", "User Name Goes Here");
// Store the key ID and contract address in our localStorage stores
keyId.set(keyId_base64);
contractId.set(cid);
if (!built) {
error(500, {
message: "built transaction missing",
});
}
// Send the transaction, fund the smart wallet, refresh the balance
await send(built);
await fundContract($contractId);
getBalance();
} catch (err) {
console.log(err);
toastStore.trigger({
message: "Something went wrong signing up. Please try again later.",
background: "variant-filled-error",
});
}
}
User login
Awesome! The user signs up and gets some (Testnet) lumens all in one go. Let's give them a way to login now with the passkey they've already associated with the smart wallet.
import { getContractId } from "$lib/passkeyClient";
async function login() {
console.log("logging in");
try {
// The connectWallet function requires us to pass a function that can
// be used to reverse-lookup the smart wallet address, provided we know
// the passkey's ID (the user supplies that during the function's execution)
const { keyId_base64, contractId: cid } = await account.connectWallet({
getContractId,
});
// Store the key ID and contract address in our localStorage stores
keyId.set(keyId_base64);
console.log($keyId);
contractId.set(cid);
console.log($contractId);
} catch (err) {
console.log(err);
toastStore.trigger({
message: "Something went wrong logging in. Please try again later.",
background: "variant-filled-error",
});
}
}
Similar, yet simpler, when compared with our signup
function. We're using the account.connectWallet
function. This function will:
- Trigger the user to authenticate, providing the passkey's ID along the way,
- Use Mercury to reverse-lookup the contract ID given that passkey ID, and finally
- Return the passkey ID and smart wallet address to our dapp.
Great! Let's get the user logged out when they need to.
User logout
This is quite a bit easier than either signup or login functions. We don't really need to communicate with the Stellar network or Mercury here. All we'll do is clear out the user state, essentially.
async function logout() {
try {
// Reset the localStorage entry for the keyId
keyId.reset();
localStorage.removeItem("yog:keyId");
// Set the contract address store to an empty string
contractId.set("");
// Refresh the page, just for good measure
window.location.reload();
} catch (err) {
console.log(err);
toastStore.trigger({
message: "Something went wrong logging out. Please try again later.",
background: "variant-filled-error",
});
}
}
With those three functions, our dapp is ready for users to authenticate with the dapp! Much easier than you probably expected it to be, right!?
The "profile menu"
Still in our connectButtons.svelte
component, we also have a collection of buttons and functions that represent a "profile menu" of sorts. The user can use these buttons to view their smart wallet balance, see it on Stellar Expert, send a donation to our (humble) guestbook maintainer, request more (Testnet) funding, etc. Much of this is unnecessary to dive into here in this tutorial, though I highly recommend taking a look at the source code to get a better understanding of this functionality.
However, we will look into the donate
function here. This is a really useful example of how a dapp can enable their smart wallet users to interact with any asset on the Stellar network. (Here, we are using Testnet XLM for our asset, but the flow would be identical for any asset you may want to use.)
The button is still pretty simple, just like the authentication buttons. We are adding some "loading" logic for when the transaction is taking place, though. So, it's got a few more bells and whistles.
<script lang="ts">
import LoaderCircle from "lucide-svelte/icons/loader-circle";
import HelpingHand from "lucide-svelte/icons/helping-hand";
let isDonating: boolean = false;
</script>
<button
class="btn variant-soft-surface w-full"
onclick="{donate}"
disabled="{isDonating}"
>
<span>
{#if isDonating}
<LoaderCircle class="animate-spin" />
{:else}
<HelpingHand />
{/if}
</span>
<span>Send Donation</span>
</button>
The donate
function takes advantage of the native
SAC client we made in the $lib/passkeyClient.ts
file. This allows us to call the transfer function of the contract just like any other JavaScript function.
import { account, send, native } from '$lib/passkeyClient';
import { keyId } from '$lib/stores/keyId';
import { contractId } from '$lib/stores/contractId';
async function donate() {
console.log('starting donation process');
isDonating = true;
try {
const user = prompt("Give this passkey a name")
const at = await native.transfer({
to: networks.testnet.contractId,
from: $contractId,
amount: BigInt(donation * 10_000_000),
});
await account.sign(at, { keyId: $keyId });
const res = await send(at.built!);
console.log(res);
toastStore.trigger({
message: 'Donation received! You really ARE the goat.',
background: 'variant-filled-success',
});
getBalance();
} catch (err) {
console.log(err);
toastStore.trigger({
message: 'Something went wrong donating. Please try again later.',
background: 'variant-filled-error',
});
} finally {
isDonating = false;
}
}
We're simplifying this function just a bit for this tutorial. In the real dapp, we're using a modal to retrieve the user's input. That ends up looking a bit too cluttered for here, though.
All in, that's a pretty easy invocation of the SAC's transfer
function. We just pass the from
, to
, and amount
fields. Then, we sign the transaction with our account
instance, providing our passkey ID in the arguments. Finally, we send the transaction using our helper function, which will fire off the request to Launchtube, and we'll be good to go. In this case, we're not really stressed about the return value. We'll just catch any errors, and notify the user with a toast message.
Enough of the account and asset things, let's get to the guestbook entries!
Sign the guestbook
First, we'll need a page that allows us to actually sign the guestbook. We'll have a form that takes a title
and message
field, and then we'll submit the transaction with the send
helper function, just like we did with the XLM transfer previously.
The form is pretty simple, and it's barely worth mentioning. We have a text input, a textarea input, and a button. Some checks are performed to see if the button should be enabled (if a user is not logged in, for example). Otherwise, it's pretty unremarkable:
<script lang="ts">
import Signature from "lucide-svelte/icons/signature";
import LoaderCircle from "lucide-svelte/icons/loader-circle";
let messageTitle: string;
let messageText: string;
let isLoading: boolean = false;
</script>
<label class="label">
<span>Title</span>
<input
bind:value="{messageTitle}"
class="input"
type="text"
placeholder="Title"
/>
</label>
<label class="label">
<span>Message</span>
<textarea
bind:value="{messageText}"
class="textarea"
rows="4"
placeholder="Write your message here"
></textarea>
</label>
<button
on:click="{signGuestbook}"
type="button"
class="btn variant-filled-primary"
disabled="{signButtonDisabled}"
>
<span>
{#if isLoading}
<LoaderCircle class="animate-spin" />
{:else}
<Signature />
{/if}
</span>
<span>Sign!</span>
</button>
The signGuestbook
function (which is executed when the button is clicked), is where the more interesting bits are. Even still, it looks quite similar to the other transactions we've submitted (account creation and XLM transfers).
import ye_olde_guestbook from '$lib/contracts/ye_olde_guestbook';
import { contractId } from '$lib/stores/contractId';
import { keyId } from '$lib/stores/keyId';
import { account, send } from '$lib/passkeyClient';
async function signGuestbook() {
try {
isLoading = true;
const at = await ye_olde_guestbook.write_message({
author: $contractId,
title: messageTitle,
text: messageText,
});
let txn = await account.sign(at.built!, { keyId: $keyId });
const { returnValue } = await send(txn.built!);
const messageId = xdr.ScVal.fromXDR(returnValue, 'base64').u32();
toastStore.trigger({
message: 'Huzzah!! You signed my guestbook! Thanks.',
background: 'variant-filled-success',
});
goto(`/read/${messageId}`);
} catch (err) {
console.log(err);
toastStore.trigger({
message: 'Something went wrong signing the guestbook. Please try again later.',
background: 'variant-filled-error',
});
} finally {
isLoading = false;
}
}
The heart and soul of this function is to invoke the write_message
function from our contract. Thanks to our generated bindings, that's really easily done.
We get the message ID as the return value, and then redirect the user to the page where they can read that particular entry.
How does this page read the guestbook entry? Excellent timing for that question!
Read guestbook entries
Read a single entry
The first page we'll create is one that reads and displays a single guestbook message from the smart contract storage. We'll use a server-side function for this. That way, if we were using a paid RPC provider, we could have this function run on the server and return the relevant data to the client.
This +page.server.ts
is a Svelte way of saying "every time this page is requested by a user, run this function on the server, and give the data to the client."
The [id]
part of the filename tells this route that we expect to have a path-based parameter, and we can use it as id
.
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 {
let { result } = await guestbook.read_message({
message_id: parseInt(params.id),
});
return {
id: params.id,
message: result.unwrap(),
};
} catch (err) {
error(500, {
message:
"Sorry, something went wrong. Most likely, the message you're looking for doesn't exist.",
});
}
};
You can see here we're using one of the contract functions, read_message
to get the data. This is a "read-only" function, meaning that no on-chain state is modified when it's run. So, we can just simulate the invocation, which is already done for you when the bindings-generated function is called, and just take the data from the simulation response! Pretty neat, right?!
We pass the resulting message details back to the page, where it will be displayed.
<script lang="ts">
import GuestbookMessage from "$lib/components/GuestbookMessage.svelte";
import type { PageData } from "./$types";
export let data: PageData;
</script>
<h1 class="h1">Read Message {data.id}</h1>
<p>
You're viewing just message {data.id}. You can
<a class="anchor" href="/read">read all of them here</a>, as well.
</p>
<!-- This component is just a wrapper with a bunch of divs. We won't worry about it right now -->
<GuestbookMessage message="{data.message}" messageId="{parseInt(data.id)}" />
Read all entries
Great! If you know the ID of the entry you want to read. Most of the time, you probably wouldn't. Let's make a page that can read/display all of the guestbook entries.
For this, we'll (again) keep as much of the query logic server-side as possible. These ledger entry results can be cached. And, the client doesn't need to make even more round trips just to query these entries. The route that performs this query is another +page.server.ts
file:
import {
getAllMessages,
getWelcomeMessage,
} from "$lib/server/getLedgerEntries";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
return {
welcomeMessage: await getWelcomeMessage(),
messages: await getAllMessages(),
};
};
We're making use of two functions that we've defined elsewhere. The welcomeMessage
will always have ID 1, and we want to always display it at the top of the page. The two functions are defined like this:
import { rpc } from '$lib/passkeyClient';
// notice our bindings re-exports the Stellar SDK, so we don't even really need
// to import any Stellar-related classes or functions from elsewhere.
import { Address, networks, Contract, type Message, xdr, scValToNative } from 'ye_olde_guestbook';
// First, we need a function to build these LedgerKeys so we can query the network
function buildMessageLedgerKey(messageId: number) {
const ledgerKey = xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: new Address(networks.testnet.contractId).toScAddress(),
key: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol('Message'), xdr.ScVal.scvU32(messageId)]),
durability: xdr.ContractDataDurability.persistent(),
}),
);
return ledgerKey;
}
// To get our welcome message, we use the `getLedgerEntries` function
// from the RPC instance.
export async function getWelcomeMessage(): Promise<Message> {
const result = await rpc.getLedgerEntries(buildMessageLedgerKey(1));
return scValToNative(result.entries[0].val.contractData().val());
}
// Our contract stores the number of guestbook messages in its instance
// storage. So, we have a function to query exactly how many messages we
// need to retrieve.
export async function getMessageCount() {
const result = await rpc.getLedgerEntries(
new Contract(networks.testnet.contractId).getFootprint(),
);
const messageCount = result.entries[0].val
.contractData()
.val()
.instance()
.storage()
?.filter((item) => item.val().switch().name === 'scvU32');
return messageCount![0].val().value() as number;
}
// Now we can iterate and make ledger key for each relevant message,
// and add that to our getLedgerEntries query. The maximum number of ledger entries
// to query is 200.
export async function getAllMessages(): Promise<Message[]> {
const totalCount = await getMessageCount();
const ledgerKeysArray = [];
for (let messageId = 2; messageId <= totalCount; messageId++) {
ledgerKeysArray.push(buildMessageLedgerKey(messageId));
}
const result = await rpc.getLedgerEntries(...ledgerKeysArray);
const messages = result.entries.map((message) => {
return {
...scValToNative(message.val.contractData().val()),
};
});
return messages;
}
Did you catch all that?! Well done! That's the querying part of reading all messages. Now, to display those messages, we get that data into our Svelte page.
<script lang="ts">
import { SlideToggle } from "@skeletonlabs/skeleton";
import GuestbookMessage from "$lib/components/GuestbookMessage.svelte";
import type { PageData } from "./$types";
export let data: PageData;
let sortNewestFirst = true;
let messages = data.messages;
$: if (sortNewestFirst) {
messages = messages.sort((a, b) => b.ledger - a.ledger);
} else {
messages = messages.sort((a, b) => a.ledger - b.ledger);
}
</script>
<div
class="flex flex-col md:flex-row justify-start md:justify-between space-y-4"
>
<div class="space-y-4">
<h1 class="h1">Read the Book</h1>
<p>Take a gander at all these messages!</p>
</div>
<div class="md:self-end">
<SlideToggle
name="sort"
bind:checked="{sortNewestFirst}"
active="bg-primary-500"
size="sm"
>Showing
<code class="code">{sortNewestFirst ? 'Newest' : 'Oldest'}</code>
First</SlideToggle
>
</div>
</div>
<GuestbookMessage message="{data.welcomeMessage}" messageId="{1}" />
<hr class="!border-t-2" />
{#each messages as message, i (message.ledger)}
<GuestbookMessage {message} messageId="{i" + 2} />
{/each}
We're loading the data we retrieve from the server. We even include a little toggle switch so the user can decide if they want to see newer or older entries first. Then, it's time to display the messages.
Again, we use the GuestbookMessage
component. We display one instance of the component for each message entry.
Edit a guestbook entry
If we take a brief look inside the GuestbookMessage
component, we can see that we have some form fields in the event the user wants to edit a message. We limit the display of these parts of the component to cases where the logged in user's smart wallet C...
address matches the guestbook entry's author
field.
The HTML of the page is outside of what we need to cover here, but suffice it to say when the user is editing an entry, the form fields behave pretty similar to the form on the "sign the guestbook" page. The functions are a bit more interesting, and more relevant to this tutorial.
The benefit of including this functionality within the message-displaying component, is that the edit functions can be used wherever the user is reading the messages. Whether they're reading through all the entries, or just a single entry, if they were the author of a message, the edit buttons will be available to them.
import { account, send } from '$lib/passkeyClient';
import { keyId } from '$lib/stores/keyId';
// This is how we receive the "props" from the pages that instantiate this component
export let message: Message;
export let messageId: number;
let editing: boolean;
let isLoading: boolean;
// Store the original values from the contract's storage. The form will be "bound"
// to these values later on, when the user is modifying the entry.
let messageTitle = message.title;
let messageText = message.text;
/**
* If the user chooses to cancel the editing the message, we should revert the
* message state back to the original values.
*/
const cancelEdit = () => {
messageTitle = message.title;
messageText = message.text;
editing = false;
};
const submitEdit = async () => {
console.log('submitting message edit');
isLoading = true;
try {
const at = await ye_olde_guestbook.edit_message({
message_id: messageId,
title: messageTitle,
text: messageText,
});
const txn = await account.sign(at.built!, { keyId: $keyId });
await send(txn.built!);
toastStore.trigger({
message: 'Message edited successfully.',
background: 'variant-filled-success',
});
} catch (err) {
console.log(err);
toastStore.trigger({
message: 'Something went wrong editing your message. Please try again later.',
background: 'variant-filled-error',
});
} finally {
editing = false;
isLoading.set(false);
}
};
Notice that, unlike when we signed the guestbook in the first place, we don't have to supply an author
argument. The smart contract is designed in a way that it looks for the author (and requires authentication) from within its own storage. This ensures that the original author of a guestbook entry is the only account authorized to make modifications to it.
Not even our gracious guestbook host could modify an entry!