diff --git a/docs/build/apps/guestbook/README.mdx b/docs/build/apps/guestbook/README.mdx index dc7f6f204f..c410483049 100644 --- a/docs/build/apps/guestbook/README.mdx +++ b/docs/build/apps/guestbook/README.mdx @@ -5,6 +5,8 @@ sidebar_position: 57 import DocCardList from "@theme/DocCardList"; -This section walks you through designing and building a decentralized application (dapp) that interacts with a smart contract guestbook, allowing users to read and write public messages. This tutorial also implements a passkey-powered smart wallet for user authentication. +This section walks you through designing and building a decentralized application (dapp) that interacts with a smart contract guestbook, allowing users to read and write public messages. The tutorial also implements a passkey-powered smart wallet for user authentication. + +That smart wallet is built with [Smart Account Kit](https://github.com/stellar/smart-account-kit) and the [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account) contracts, and its transactions are submitted by the [OpenZeppelin Relayer](https://docs.openzeppelin.com/relayer/guides/stellar-channels-guide) running the Stellar Channels plugin. diff --git a/docs/build/apps/guestbook/bindings.mdx b/docs/build/apps/guestbook/bindings.mdx index 13ba71b1b6..eacf9f3fa2 100644 --- a/docs/build/apps/guestbook/bindings.mdx +++ b/docs/build/apps/guestbook/bindings.mdx @@ -19,37 +19,41 @@ We'll be generating our contract bindings, and keeping them in the same reposito - Your deploy process might include a step that builds/deploys/binds a contract package at deploy-time. - You could even generate and publish a bindings package all by itself. Then `pnpm install ` can be done in any dapp that you (or somebody else) might need to interact with that contract. -### The manual method +## The manual method Before you skip ahead! Take a look at this (brief) section. It's _really_ useful to have a full understanding of what steps we're going through in the automated section. This will help you adapt and/or troubleshoot this tutorial for your specific purposes. -#### Install the compiled contract +### Install the compiled contract The smart contract code needs to be installed to the network first. This uploads the compiled, binary Wasm file to the blockchain to be instantiated into a contract later on. From inside your project directory: -```shell +```sh stellar contract upload \ --source-account \ --network testnet \ --wasm ./target/wasm32v1-none/release/ye_olde_guestbook.wasm ``` -#### Deploy a contract instance +### Deploy a contract instance -This will return a hexadecimal hash corresponding to the uploaded Wasm executable. This hash can then be used in the deploy command to create a new contract instance: +This will return a hexadecimal hash corresponding to the uploaded Wasm executable (it's just the Sha256 hash of the executable file, fyi). This hash can then be used in the deploy command to create a new contract instance. Our `__constructor` function takes an `admin` address, and the `title` and `text` of the first guestbook message, so we supply those arguments after the `--` separator: -```shell +```sh stellar contract deploy \ --source-account \ --network testnet \ - --wasm-hash + --wasm-hash \ + -- \ + --admin \ + --title "Welcome!" \ + --text "Thanks for visiting. Please sign my guestbook!" ``` -#### Generate bindings for the deployed contract +### Generate bindings for the deployed contract Now we can (again) use the Stellar CLI to generate bindings from the contract we've just deployed. You can also generate these bindings from your local Wasm file using the `--wasm-hash` parameter. The `--overwrite` parameter is used to tell the CLI that it should output the generated bindings package, even if it finds the directory is not empty (i.e., we're re-binding a contract because we've modified the code and redeployed it). -```shell +```sh stellar contract bindings typescript \ --network testnet \ --id \ @@ -57,58 +61,82 @@ stellar contract bindings typescript \ --overwrite ``` -We'll need to build the bindings package, since (in its initial state) the package is mostly TypeScript types and stubs for the various contract functions. +The guestbook keeps its bindings packages in a [pnpm workspace](https://pnpm.io/workspaces), so `packages/*` is already claimed by the workspace glob and our freshly generated package is picked up automatically: -```shell -cd packages/ye_olde_guestbook -pnpm install -pnpm run build -cd ../.. +```yaml title="pnpm-workspace.yaml" +packages: + - "packages/*" ``` -#### Import the bindings package as a project dependency +That leaves two bits of housekeeping. The CLI writes a _standalone_ package, so it ships its own `pnpm-lock.yaml`, which you don't want inside a workspace (the root lockfile is the only one that matters). And the generated `package.json` only defines a `build` script, so we'll add a `prepare` script, which lets pnpm compile the bindings on every workspace install. That last one is nicer than it sounds: it means the built `dist/` directory never has to be committed. -With our bindings generated, we can add it to our frontend project. Run this from the root of your project: +```sh +rm -f packages/ye_olde_guestbook/pnpm-lock.yaml +pnpm --filter ye_olde_guestbook pkg set scripts.prepare=tsc +``` + +:::tip[Customize your bindings] + +You could take this opportunity to customize your generated bindings _before_ you build them. By default, generated bindings will re-export the entirety of `@stellar/stellar-sdk` for your frontend application. If this behavior isn't desired, you can remove it. These packages are your own to modify as you see fit. + +::: + +### Import the bindings package as a project dependency + +With our bindings generated, we can add it to our frontend project. Because it's a workspace package, we don't point at a file path. We let pnpm resolve it from the workspace instead, running this from the root of the project: -```shell -pnpm add file:./packages/ye_olde_guestbook +```sh +pnpm add -D ye_olde_guestbook --workspace ``` -#### Import the bindings client into the SvelteKit project +That records `"ye_olde_guestbook": "workspace:*"` in your root `package.json`, and pnpm _links_ the package out of `packages/` rather than copying it. So when you re-generate the bindings after changing your contract, your frontend picks up the new version with no re-install. + +This is also the moment the bindings actually get compiled. Adding the dependency runs an install, the install runs the `prepare` script we just added, and `prepare` runs `tsc`. You'll see pnpm report it as it goes. + +:::note + +Earlier versions of this tutorial used `pnpm add file:./packages/ye_olde_guestbook` here. That still resolves, but with a workspace declared the `--workspace` form is the right idiom, and it's what the guestbook's own `package.json` records. + +::: + +### Import the bindings client into the SvelteKit project :::info -We're straying just a _bit_ into the Svelte-ish side of things here. The main goal of this step is to get the contract client (which is the "bindings package" we've just generated) into our frontend in a way that makes it usable anywhere we need it. In SvelteKit, we put it into `src/lib/contracts` because that means we can easily access the client by importing from `$lib/contracts/ye_olde_guestbook` whenever and wherever we need it. +We're straying just a _bit_ into the Svelte-ish side of things here. The main goal of this step is to get the contract client (which is the "bindings package" we've just generated) into our frontend in a way that makes it usable anywhere we need it. In SvelteKit, we're putting it into `src/lib/contracts` because that means we can easily access the client by importing from `$lib/contracts/ye_olde_guestbook` whenever and wherever we need it. ::: Now, we'll define the contract client in a way we can easily access it through the rest of our app. -```js title="src/lib/contracts/ye_olde_guestbook.ts" -import * as Client from "ye_olde_guestbook"; // import the package we just added as a dependency -import { PUBLIC_STELLAR_RPC_URL } from "$env/static/public"; // import the RPC url from the .env file +```ts title="src/lib/contracts/ye_olde_guestbook.ts" +import { Client, networks } from "ye_olde_guestbook"; +import { PUBLIC_STELLAR_RPC_URL } from "$env/static/public"; -// instantiate and export the Client class from the bindings package -export default new Client.Client({ - ...Client.networks.testnet, // this includes the contract address and network passphrase - rpcUrl: PUBLIC_STELLAR_RPC_URL, // this is required to invoke the contract through RPC calls +// `networks.testnet` contains the contract address and network passphrase +// baked in at bindings-generation time. +export default new Client({ + ...networks.testnet, + rpcUrl: PUBLIC_STELLAR_RPC_URL, }); ``` -### The automated way +## The automated way + +That was a lot of steps and a lot of work wasn't it!? -That was a lot of steps and a lot of work wasn't it!? The good news is that our starter template (remember that?) comes with an `initialize.js` script that will perform all of those actions for you! This script will go through all the following steps for you: +The good news is that our starter template (remember [that](./overview.mdx#start-from-the-stellar-template-repository)?) comes with an `initialize.js` script that will perform all of those actions for you! This script will go through all the following steps for you: - Create and fund a keypair in the CLI -- Install and deploy **all contracts** in the `/contracts` directory -- Generate bindings from the deployed contracts +- Compile, install, and deploy **all contracts** in the `/contracts` directory +- Generate bindings from the deployed contracts, and settle each package into the workspace: add the `prepare` script, gitignore the compiled `dist/` directory, and delete the standalone lockfile the CLI writes - Create a `$lib/contracts/.ts` file for easy import into your frontend code -You can always customize this script to suit your needs. Check out the [source code here](https://github.com/ElliotFriend/soroban-template-sveltekit-passkeys/blob/main/initialize.js) (which has been documented with comments). Or, you can see the [officially maintained script](https://github.com/stellar/soroban-template-astro/blob/main/initialize.js) in the [`soroban-template-astro` repository](https://github.com/stellar/soroban-template-astro), as well. +You can always customize this script to suit your needs. Check out the [source code here](https://github.com/ElliotFriend/stellar-template-sveltekit-passkeys/blob/main/initialize.js) (which has been documented with comments). Or, you can see the [officially maintained script](https://github.com/stellar/soroban-template-astro/blob/main/initialize.js) in the [`soroban-template-astro` repository](https://github.com/stellar/soroban-template-astro), as well. Run the initialization script like so: -```shell +```sh node initialize.js ``` @@ -120,10 +148,10 @@ For a more comprehensive overview of the process of creating, customizing, and u We've also added a command to the `package.json` scripts, so you can run this initialize script simply by running (from your project's root directory): -```shell +```sh pnpm run setup ``` -Right, so we've now created a starter project, written a guestbook smart contract, and generated an NPM package that will help us interact with that contract on the network. Amazing! +Right, so we've now cloned the starter project, written a guestbook smart contract, and generated an NPM package that will help us interact with that contract on the network. Amazing! -Next up, let's take a look at how our users will connect with and interact with our dapp. It's time for passkeys! (insert air horn noises)📢 +Next up, let's set up the one prerequisite our passkey-powered smart wallets need: an OpenZeppelin Relayer API key. diff --git a/docs/build/apps/guestbook/frontend.mdx b/docs/build/apps/guestbook/frontend.mdx index 978c2ff44e..3fac207fbb 100644 --- a/docs/build/apps/guestbook/frontend.mdx +++ b/docs/build/apps/guestbook/frontend.mdx @@ -7,348 +7,325 @@ 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. +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.). :::info -We're using some pieces of [Svelte state](https://svelte.dev/docs/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. +We're using [Svelte state](https://svelte.dev/docs/svelte/$state) to keep track of the user's smart account address, in the `wallet` object we built on the [previous page](./setup-passkeys.mdx#tracking-the-connected-wallet). 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 +### 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 `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: -The buttons themselves are simple enough: +```svelte title="src/lib/components/ConnectButtons/ConnectButtons.svelte" + -```html title="src/lib/components/connectButtons.svelte"
- - - + {#if !wallet.contractAddress} + + + {:else} + + {/if}
``` -If you look at the [source code](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/src/lib/components/ConnectButtons.svelte) 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. +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 begin with the Signup function. +Let's dig into each interaction. #### 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. +For signup, we call `account.createWallet()`. Under the hood, Smart Account Kit: -```js title="src/lib/components/connectButtons.svelte" -import { account, send, fundContract } from "$lib/passkeyClient"; -import { keyId } from "$lib/stores/keyId"; -import { contractId } from "$lib/stores/contractId"; +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](https://docs.openzeppelin.com/stellar-contracts/accounts/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`). -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", - }); +```svelte title="src/lib/components/ConnectButtons/Signup.svelte" + ``` -#### User login +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. -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. +`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. Every passkey flow in this app checks for the dismissal first. -```js title="src/lib/components/connectButtons.svelte" -import { getContractId } from "$lib/passkeyClient"; +#### User login -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, - }); +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. - // 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", - }); - } -} -``` +```svelte title="src/lib/components/ConnectButtons/Login.svelte" + -1. Trigger the user to authenticate, providing the passkey's ID along the way, -2. Use Mercury to reverse-lookup the contract ID given that passkey ID, and finally -3. Return the passkey ID and smart wallet address to our dapp. + +``` -Great! Let's get the user logged out when they need to. +This is the flow that used to need Mercury and a Zephyr program of your own. The credential-to-contract lookup still has to happen, it's just not your problem any more: the kit checks its local IndexedDB index first and falls back to a hosted indexer. Have a look back at the [prerequisites](./passkeys-prerequisites.mdx) 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." #### 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. +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. -```js title="src/lib/components/connectButtons.svelte" +```ts title="src/lib/components/ConnectButtons/Settings.svelte" 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", - }); + await account.disconnect(); + } catch (err: unknown) { + console.error("[logout]", err); + // ...omitted: the error toast } } ``` -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!? +With those three flows our dapp is ready to onboard users. ### 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](https://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](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/src/lib/components/ConnectButtons.svelte) to get a better understanding of this functionality. +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). -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.) +#### Reading the balance -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. +This one's already done. We wrote `getNativeBalance` back on the [setup page](./setup-passkeys.mdx#a-couple-of-helpers), 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. -```html title="src/lib/components/connectButtons.svelte" - - - +async function getBalance() { + try { + balance = (await getNativeBalance(wallet.contractAddress!)).toString(); + } catch (err: unknown) { + console.error("[balance]", err); + // ...omitted: the error toast + } +} ``` -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. - -```js title="src/lib/components/connectButtons.svelte" -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; - } +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. + +```ts title="src/lib/components/ConnectButtons/Settings.svelte" +// `account.fundWallet()` reports expected failures in the result rather +// than throwing, but `toaster.promise` depends on that rejection. +async function fundWallet() { + const result = await account.fundWallet(PUBLIC_NATIVE_TOKEN_CONTRACT); + + if (!result.success) { + throw result.error; + } + + return result; } ``` -:::info +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. + +:::note -We're simplifying this function _just a bit_ for this tutorial. In the [real dapp](https://github.com/ElliotFriend/ye-olde-guestbook/blob/1a55a5238a71b624b789dfd82a6d7fd996407bd7/src/lib/components/ConnectButtons.svelte#L138), we're using a modal to retrieve the user's input. That ends up looking a bit too cluttered for here, though. +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). ::: -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. +#### Sending a donation -Enough of the account and asset things, let's get to the guestbook entries! +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. -## Sign the guestbook +It's also a one-liner: -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. +```svelte title="src/lib/components/ConnectButtons/DonateButton.svelte" + +``` -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: +`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. -```html title="src/routes/sign/+page.svelte" - +## 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: -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). +```ts title="src/routes/sign/+page.svelte" +import { goto } from "$app/navigation"; +import { resolve } from "$app/paths"; +import { Api } from "@stellar/stellar-sdk/rpc"; -```js title="src/routes/sign/+page.svelte" -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'; +import { account, rpc } from "$lib/smartAccountClient"; +import { wallet } from "$lib/state/UserState.svelte"; +import ye_olde_guestbook from "$lib/contracts/ye_olde_guestbook"; 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; + try { + if (!wallet.contractAddress) { + throw "user missing contract address"; + } + const at = await ye_olde_guestbook.write_message({ + author: wallet.contractAddress, + title: messageTitle, + text: messageText, + }); + + const result = await account.signAndSubmit(at); + + if (!result.success) { + throw result.error; + } + + // The relayer reports a hash rather than the invocation's return + // value, so read the new message's id back from the network. + const response = await rpc.pollTransaction(result.hash); + if ( + response.status !== Api.GetTransactionStatus.SUCCESS || + !response.returnValue + ) { + throw new Error(`Transaction ${result.hash} did not return a value`); } + const messageId = response.returnValue.u32(); + + goto(resolve(`/read/${messageId}`)); + } catch (err: unknown) { + console.error("[sign]", err); + } + // ...omitted: success and error toasts, and the isLoading flag } ``` -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. +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. -We get the message ID as the return value, and then redirect the user to the page where they can read _that_ particular entry. +:::caution -How does this page read the guestbook entry? Excellent timing for that question! - -## Read guestbook entries +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. -### Read a single entry +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. -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. +::: -:::info +With the ID in hand, we redirect the user to the page for their shiny new entry. -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." +## Read guestbook entries -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`. +### 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. -```js title="src/routes/read/[id]/+page.server.ts" +```ts title="src/routes/read/[id]/+page.server.ts" 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({ + const { result } = await guestbook.read_message({ message_id: parseInt(params.id), }); @@ -356,7 +333,8 @@ export const load: PageServerLoad = async ({ params }) => { id: params.id, message: result.unwrap(), }; - } catch (err) { + } catch (err: unknown) { + console.error(err); error(500, { message: "Sorry, something went wrong. Most likely, the message you're looking for doesn't exist.", @@ -365,237 +343,80 @@ export const load: PageServerLoad = async ({ params }) => { }; ``` -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. - -```html title="src/routes/read/[id]/+page.svelte" - - -

Read Message {data.id}

-

- You're viewing just message {data.id}. You can - read all of them here, as well. -

+`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 -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: +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 a single batched call for every message ID from there. -```js title="src/routes/read/+page.server.ts" -import { - getAllMessages, - getWelcomeMessage, -} from "$lib/server/getLedgerEntries"; -import type { PageServerLoad } from "./$types"; - -export const load: PageServerLoad = async () => { - return { - welcomeMessage: await getWelcomeMessage(), - messages: await getAllMessages(), - }; -}; -``` +```ts title="src/lib/server/getLedgerEntries.ts" +export async function getAllMessages(): Promise { + const totalCount = await getMessageCount(); + const ledgerKeysArray = []; -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: - -```js title="src/lib/server/getLedgerEntries.ts" - -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 { - 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 { - 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. - -```html title="src/routes/read/+page.svelte" - -
-
-

Read the Book

-

Take a gander at all these messages!

-
-
- Showing - {sortNewestFirst ? 'Newest' : 'Oldest'} - First -
-
+ const result = await rpc.getLedgerEntries(...ledgerKeysArray); + const messages = result.entries.map((message) => { + const key = scValToNative(message.val.contractData().key())[1]; // scVal of the key is ['Message', 2] + const val = scValToNative( + message.val.contractData().val(), + ) as MessageWithIndex; + val.id = key; - -
+ return val; + }); -{#each messages as message, i (message.ledger)} - -{/each} + return messages; +} ``` -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. +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 all of them in one request. See the [full implementation](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/src/lib/server/getLedgerEntries.ts) for the key-building helper and the message-count read. ## Edit a guestbook entry -If we take a brief look inside the [`GuestbookMessage` component](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/src/lib/components/GuestbookMessage.svelte), 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. +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. -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. +```ts title="src/lib/components/GuestbookMessage.svelte" +import ye_olde_guestbook from "$lib/contracts/ye_olde_guestbook"; +import { account } from "$lib/smartAccountClient"; +import { wallet } from "$lib/state/UserState.svelte"; -:::tip - -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. +const submitEdit = async () => { + try { + if (!wallet.contractAddress) { + throw "user missing contract address"; + } -::: + const at = await ye_olde_guestbook.edit_message({ + message_id: messageId, + title: messageTitle, + text: messageText, + }); -```js title="src/lib/components/GuestbookMessage.svelte" -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 result = await account.signAndSubmit(at); -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); + if (!result.success) { + throw result.error; } + } catch (err: unknown) { + console.error("[edit]", err); + } + // ...omitted: the toasts, and the isEditing/isLoading flags }; ``` -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. +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. -Not even our gracious guestbook host could modify an entry! +Way to go! Now go put something in the guestbook. diff --git a/docs/build/apps/guestbook/overview.mdx b/docs/build/apps/guestbook/overview.mdx index 4971cde0ab..eca23c396f 100644 --- a/docs/build/apps/guestbook/overview.mdx +++ b/docs/build/apps/guestbook/overview.mdx @@ -3,7 +3,7 @@ title: Overview sidebar_position: 10 --- -In this tutorial, we'll walk you through building an old-timey [internet guestbook](https://en.wikipedia.org/wiki/Guestbook)! (Trust me, they were all the rage back in the day.) We'll be examining how the project is constructed, starting with the smart contract. Then, we'll turn that deployed smart contract into a "bindings package," allowing us to seamlessly integrate it into our frontend project. To get our users authenticated, we'll be using Stellar's new passkeys capability and giving each of our users their very own smart wallet. As a bonus, this guestbook is _already_ a usable project (on Testnet) you can experiment with and use **right now**! After this tutorial, you'll have a solid understanding of how smart contracts and web applications can work together in harmony. You'll also have practical tools and examples for how you might integrate passkey-powered smart wallets into your own projects. +In this tutorial, we'll walk you through building an old-timey [internet guestbook](https://en.wikipedia.org/wiki/Guestbook). (Trust me, they were all the rage back in the day.) We'll examine how the project is constructed, starting with the smart contract, then turn that deployed contract into a "bindings package" we can integrate into our frontend. To authenticate users we'll use Stellar's passkey support via [Smart Account Kit](https://github.com/stellar/smart-account-kit), giving each user their very own [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account). After this tutorial, you'll have a solid understanding of how smart contracts and web applications can work together, plus practical examples for integrating passkey-powered smart wallets into your own projects. For this tutorial, we'll walk through the steps as we build a sample application we've called [Ye Olde Guestbook](https://ye-olde-guestbook.vercel.app)[^1], which will be used to showcase various features. @@ -21,56 +21,57 @@ Although Ye Olde Guestbook is a full-fledged application on Stellar's Testnet, i To build this guestbook application, we'll need a few pieces. -- **Application framework:** we're using SvelteKit, opting for a type-checked project using TypeScript. SvelteKit (and Svelte on its own) is quite a capable framework, and we'll be using some of its features in this project. However, we will not be diving into those Svelte-specific areas very heavily in this tutorial. The source code of the project is freely open and available and has some decent informational comments throughout if you would like to peruse it for those purposes. -- **Frontend framework:** We're using [Skeleton](https://www.skeleton.dev) to simplify the use of [Tailwind CSS](https://tailwindcss.com). -- **A way to interact with the network:** this is a TypeScript application, and we're using the `@stellar/stellar-sdk` for this. You could make traditional `fetch` requests if you wanted to, depending on your deployment decisions. In either case, we'll need the SDK to interact with keypairs and transactions. We'll also be using a data indexer to access historical network events, and we'll cover more of this at a later point in the tutorial. -- **A way to interact with a user's account:** we're foregoing the traditional wallets here, and we'll use `passkey-kit` to give our users a smart wallet to interact with. They can interact with this smart wallet (via passkey-kit) through methods they're already familiar with (thumbprints, Face ID, etc.). +- **Application framework:** we're using SvelteKit with TypeScript. SvelteKit (and Svelte on its own) is quite a capable framework, and we'll be using some of its features in this project. The source code is freely open and available with informational comments throughout if you'd like to peruse it. +- **Frontend framework:** [Skeleton](https://www.skeleton.dev/) simplifies working with [Tailwind CSS](https://tailwindcss.com/). +- **A way to interact with the network:** this is a TypeScript application, so we're using `@stellar/stellar-sdk` to build transactions and work with keypairs. +- **A way to interact with a user's account:** we're foregoing traditional wallets here, and using [Smart Account Kit](https://github.com/stellar/smart-account-kit) to give each user a smart account they unlock with a passkey (Touch ID, Face ID, a hardware key, etc.). Smart Account Kit is the successor to `passkey-kit`; it targets the audited OpenZeppelin Smart Account contracts and integrates natively with the OpenZeppelin Relayer for fee sponsorship. +- **A relayer for fee sponsorship:** the [OpenZeppelin Relayer](https://docs.openzeppelin.com/relayer/guides/stellar-channels-guide) (Stellar Channels plugin) submits our users' transactions on-chain without exposing them to fees or sequence-number management. It replaces the retired Launchtube service. :::note -While we are using the above components to construct our application, we have done our best to write this tutorial in such a way that dependency on any one of these things is minimized. Ideally, you should be able to use the TypeScript code we've written and plug it into any other framework you'd like with minimal effort. +While we use the above components to construct our application, we've worked to minimize dependency on any one of them. Ideally you should be able to lift most of the TypeScript code we've written into another framework with minimal effort. ::: Some choices we've made during the course of development: -- Some of the non-Stellar components lean a _bit more_ into the Svelte way of doing things, but we've worked to make it fairly easily translatable into React, Astro, etc. -- This project is written so that a single deployment of the app interacts with a single deployment of the smart contract. It could be written differently, but we haven't here for the sake of simplicity. -- We're rolling our own passkeys service here. That means we'll set up and use `passkey-kit` (both client- and server-side components) in our own dapp. In the long run, this may not be the necessary usage pattern. It's likely that services will crop up to act as a "wallet factory" that can create smart wallets, and facilitate adding signers for various applications. Perhaps these services will be provided by existing wallets? Perhaps these services will be unknown to the user (and maybe even developers) in the future? Who knows! The sky's the limit! (But that's not the case yet, so we're doing it ourselves.) -- It should be _relatively_ responsive, no promises, though +- Some of the non-Stellar components lean into the Svelte way of doing things, but we've tried to keep them translatable to React, Astro, etc. +- A single deployment of the app interacts with a single deployment of the smart contract. This is for simplicity's sake. +- There's a mix of client- and server-side logic. The one sensitive credential in the project (the relayer API key) lives in server-only code, which SvelteKit keeps out of the browser bundle. - We've chosen a theme from Skeleton, so it looks nice right away. -- There's a mix of client- and server-side logic. This is due to the fact that we'll need to keep some authentication credentials secret, and we want to avoid leaking these to the user-facing code. Some of these techniques are a bit SvelteKit-specific, but it should ultimately be understandable. -- We're deploying to a free-tier Vercel project. We've had really good success in getting SvelteKit and Stellar projects deployed easily and quickly, and with very little configuration. Your mileage may vary, but this should be a pretty decent starting point. -- The application is likely not as performant as it could be. Neither is it as optimized as it could be. We've tried to encapsulate the various functionalities in a way that makes sense to the developer reading the codebase, so there is some code duplication and things could be done in a "better" way. -- We do _some_ error handling, but not nearly as much as you would want for a real-world application. If something seems like it's not working, and you're not seeing an error, open your developer console, and you might be able to figure out what has gone wrong. -- We have not implemented _any_ automated testing. You'll probably want some for your application. +- We're deploying to a free-tier [Vercel](https://vercel.com) project using `@sveltejs/adapter-vercel`, which gets the whole app (static assets and the server route that proxies the relayer) deployed as a single site. We've had really good success getting SvelteKit and Stellar projects up this way with very little configuration. Your mileage may vary, but it's a pretty decent starting point. +- The app is not as performant or optimized as it could be. We've prioritized readability so you can trace functionality easily. +- We do _some_ error handling, but not nearly as much as a production app would need. If something seems off, open your browser's dev console, which is usually where the answer lives. +- There's **no** automated testing. You'll want some for your own application. :::note -This tutorial is probably best viewed as "_nearly_ comprehensive." We aren't going to walk you through each and every file in our codebase, and the files we do use to illustrate concepts in the tutorial may not be _entirely_ present or explained. However, we will cover the basics, and point you to more complete examples in the codebase when applicable. +This tutorial is "_nearly_ comprehensive." We won't walk you through every file, and the files we do cover may not be entirely present or explained. We'll cover the basics and point to the codebase for the full picture. ::: ### Dev Helpers -- [Passkey Kit](https://github.com/kalepail/passkey-kit): A TypeScript SDK for creating and managing Stellar smart wallets. -- [Launchtube](https://launchtube.xyz): Similar to a [Paymaster](https://eips.ethereum.org/EIPS/eip-4337#extension-paymasters) in the EVM world, the Launchtube service aims to alleviate all of the challenges and complexities of getting a transaction on-chain by giving you an API that accepts Soroban ops and then handles getting those entries successfully submitted to the network. -- [Stellar Lab](https://lab.stellar.org): An experimental playground to interact with the Stellar network. +- [Smart Account Kit](https://github.com/stellar/smart-account-kit): TypeScript SDK for creating and managing Stellar smart accounts (the successor to `passkey-kit`). +- [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account): the audited contract framework each user's smart account is deployed from. +- [OpenZeppelin Relayer (Stellar Channels guide)](https://docs.openzeppelin.com/relayer/guides/stellar-channels-guide): the hosted relayer service. Similar to a [Paymaster](https://eips.ethereum.org/EIPS/eip-4337#extension-paymasters) in the EVM world. +- [Stellar Lab](https://lab.stellar.org): an experimental playground for interacting with the Stellar network. ## Getting Started -Here are the steps we've taken to start building Ye Olde Guestbook. Feel free to be inspired and customize these directions as you see fit. The entire [Ye Olde Guestbook codebase](https://github.com/elliotfriend/ye-olde-guestbook) is freely open and available on GitHub for reference. +Here are the steps we've taken to start building Ye Olde Guestbook. Feel free to be inspired and customize these directions as you see fit. The entire [Ye Olde Guestbook codebase](https://github.com/ElliotFriend/ye-olde-guestbook) is freely open and available on GitHub for reference. -### Start from the `soroban-template` repository +### Start from the `stellar-template` repository -With the move to smart contract development, a newly emerging utility in the Stellar ecosystem is the "[Soroban template](../../guides/dapps/soroban-contract-init-template.mdx)." These templates can help alleviate the burden of writing boilerplate code, and can help adapt typical Stellar development workflows into framework-specific reference templates. We've created [just such a template](https://github.com/ElliotFriend/soroban-template-sveltekit-passkeys) that can help you get started developing with SvelteKit and passkeys from the very beginning. You can either use the template on the GitHub website: +With the move to smart contract development, a newly emerging utility in the Stellar ecosystem is the "[Stellar template](../../guides/dapps/soroban-contract-init-template.mdx)." These templates can help alleviate the burden of writing boilerplate code, and can help adapt typical Stellar development workflows into framework-specific reference templates. We've created [just such a template](https://github.com/ElliotFriend/stellar-template-sveltekit-passkeys) that can help you get started developing with SvelteKit and passkeys from the very beginning. You can either use the template on the GitHub website: ![Github Template Project](/assets/guestbook/github_template.png) Or, you can (fork and) clone the template repository locally, and start working that way: -```shell -git clone https://github.com/ElliotFriend/soroban-template-sveltekit-passkeys ye-olde-guestbook +```sh +git clone https://github.com/ElliotFriend/stellar-template-sveltekit-passkeys ye-olde-guestbook +cd ye-olde-guestbook ``` This frontend template will give you some scaffolding and some (opinionated) defaults. What you do from there is up to you! @@ -79,33 +80,48 @@ This template will give you a few things to help you hit the ground running: - a starter `/contracts` directory with a `hello_world` contract in it, - a pre-configured set of dependencies and packages, including the `hello_world` bindings package, -- boilerplate passkey logic and helpers already written out-of-the-box, +- boilerplate smart account logic already wired up in `src/lib/smartAccountClient.ts`, alongside the `/api/send` route that proxies the relayer, - an initialization script to deploy contracts and generate bindings for them, and -- you'll have a ready-to-customize SvelteKit site, written using TypeScript. +- a ready-to-customize SvelteKit site, written using TypeScript. What more could you want!? +:::info + +Prefer to read the finished thing rather than build up to it? The [guestbook repository](https://github.com/ElliotFriend/ye-olde-guestbook) is the same app at the end of this tutorial, so you can clone that instead and follow along in the source: + +```sh +git clone https://github.com/ElliotFriend/ye-olde-guestbook +cd ye-olde-guestbook +``` + +::: + ### Set up the `.env` file -The template comes with a `.env.example` file, that you will need to modify. First, copy or move this file to `.env`: +The template comes with a `.env.example` file that you'll need to modify. First, copy or move it to `.env`: -```shell +```sh cp .env.example .env ``` -Then, open up the `.env` file, and begin customizing any of the entries you need. If you're planning to run on Testnet (and you _should_ start there), most of the variables will be suitable as-is. +Then open up the `.env` file and begin customizing the entries you need. If you're planning to run on Testnet (and you _should_ start there), there's exactly one value you have to supply yourself: + +```sh +# OpenZeppelin Channels relayer (Testnet). Generate an API key at +# https://channels.openzeppelin.com/testnet/gen +PRIVATE_RELAYER_API_KEY= +``` -Some variables you will want to change include: +You may also want to change the identity the initialization script uses when it deploys your contract: -```shell -PUBLIC_STELLAR_ACCOUNT=stroopy # you're welcome to use stroopy, but if you have an name you'd prefer, put that here -PRIVATE_FUNDER_SECRET_KEY=S...ECRET # fund an account on Testnet and put the secret key here -PUBLIC_FUNDER_PUBLIC_KEY=G...ADDRESS # put the public key from the funded account here +```sh +PUBLIC_STELLAR_ACCOUNT=stroopy # you're welcome to use stroopy, but if you have another name you'd prefer, put that here ``` -### Install Dependencies +The Smart Account Wasm hash and WebAuthn verifier address come pre-filled with the shared Testnet deployment values, so there's no action needed there unless you want to pin to a different deployment. -With our pre-existing template, everything you need should be pulled in from the `package.json` and `Cargo.toml` files. All you've got to do is open up a terminal and install the dependencies: +### Install dependencies ```bash pnpm install @@ -113,7 +129,7 @@ pnpm install :::note -We're not aiming to dictate which package manager you should use. When building a full-stack SvelteKit app using Stellar and passkeys, we've recently seen a lot of success and reliability using `pnpm`. Who's to say _why_ that's the case, and it certainly could be a fluke and limited to our own experience. In any case, we'll be using `pnpm` for the remainder of this tutorial. +We're using `pnpm` in this tutorial because it handles SvelteKit + Stellar projects reliably, but the commands translate straightforwardly to `npm` or `yarn` if you prefer. ::: diff --git a/docs/build/apps/guestbook/passkeys-prerequisites.mdx b/docs/build/apps/guestbook/passkeys-prerequisites.mdx index 9bf206cccc..ce90c09f9a 100644 --- a/docs/build/apps/guestbook/passkeys-prerequisites.mdx +++ b/docs/build/apps/guestbook/passkeys-prerequisites.mdx @@ -3,93 +3,70 @@ title: Passkeys Prerequisites sidebar_position: 40 --- -Passkeys are an amazing way to help dapp developers (like yourself) connect users with their projects, protocols, applications, etc. Learn more on the [passkey wallet guide](../../guides/contract-accounts/smart-wallets.mdx). +Passkeys are a great way to connect users with your dapp without the friction of seed phrases or browser extensions. Learn more on the [smart wallets guide](../../guides/contract-accounts/smart-wallets.mdx). -We have been hard at work pioneering some tools to increase the adoption and ease-of-use for passkeys on Stellar. For this tutorial we'll be using the **incredible** [`passkey-kit` package](https://github.com/kalepail/passkey-kit), which takes SO MUCH of the headache and hassle out of the equation. +For this tutorial we'll be using [Smart Account Kit](https://github.com/stellar/smart-account-kit), which handles the WebAuthn ceremony and deploys an audited [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account) for each of your users. -Before we get into the nitty gritty on passkeys, we have some chores to do. First, we'll set up Launchtube, a service that will help get our transactions on-chain without worrying about gas fees, sequence numbers, or source accounts. Really useful. Then, we'll create a Mercury indexing program, which will be used to keep track of the public key half of a user-generated passkey and then do a reverse lookup to see which smart wallet address the passkey has been added to. +Here's the good news: this page used to be a long one. The previous version of this tutorial had you provision a Launchtube token _and_ compile and deploy a Zephyr program to Mercury before you could write a single line of passkey code. Both of those chores are gone. You have exactly one prerequisite now: an API key for the relayer. -## Launchtube +## OpenZeppelin Relayer -Let's start with [Launchtube](https://launchtube.xyz). As we mentioned earlier, Launchtube is similar to a "paymaster" service, if you're familiar with account abstraction in EVM networks. We won't actually need to interact with Launchtube _directly_. All that will be handled by the `passkey-kit` package. However, we'll need to get a JWT token that will allow us to authenticate our dapp with Launchtube. +Earlier versions of this tutorial used Launchtube as a paymaster service. Launchtube has since been retired (the [`stellar/launchtube`](https://github.com/stellar/launchtube) repository is archived and the hosted service is offline), and its successor is the [OpenZeppelin Relayer](https://docs.openzeppelin.com/relayer/guides/stellar-channels-guide) running the Stellar Channels plugin. The relayer submits transactions on your users' behalf through a pool of managed channel accounts, so it takes care of fees, sequence numbers, and source accounts. If you're coming from the EVM world, this is the rough equivalent of a [paymaster](https://eips.ethereum.org/EIPS/eip-4337#extension-paymasters). -For Testnet Launchtube tokens, we can generate one any time we like. All you have to do is visit `https://testnet.launchtube.xyz/gen` to receive a JWT token that will be valid for three months, and will have 100 XLM in credits (these credits will be consumed when you submit network transactions through Launchtube). Go ahead, [give it a try](https://testnet.launchtube.xyz/gen)! +We'll use the hosted Testnet relayer at `https://channels.openzeppelin.com/testnet`. To authenticate with it, you'll need an API key. -:::tip +1. Visit [https://channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen) to generate a Testnet key. +2. Copy the key into your `.env` file: -We do have Mainnet Launchtube tokens available! You can request a token in the [`#launchtube` channel](https://discord.com/channels/897514728459468821/1293204627361108141) on our [Stellar Developer Discord server](https://discord.gg/stellardev). In particular, pinging `@kalepail`, `@ElliotFriend`, or `@carsten.xlm` should get you on your way pretty quickly. - -::: - -Once you have your Launchtube token, copy/paste it into the `.env` file, as the `PRIVATE_LAUNCHTUBE_JWT` variable: + ```sh + PRIVATE_RELAYER_BASE_URL="https://channels.openzeppelin.com/testnet" + PRIVATE_RELAYER_API_KEY= + ``` -```shell -PRIVATE_LAUNCHTUBE_JWT= -``` +That's the whole prerequisite. Told you it was shorter. :::info -The `PRIVATE_` and `PUBLIC_` environment variables are a SvelteKit convention, allowing us to access these variables in appropriate places throughout our codebase using the [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) and [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) modules, respectively. +The `PRIVATE_` and `PUBLIC_` prefixes are a SvelteKit convention. Variables prefixed `PRIVATE_` are only readable from server-side code via [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private), so the relayer key never ships to the browser. Variables prefixed `PUBLIC_` are inlined into the client bundle via [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public). ::: -## Mercury - -Now, on to [Mercury](https://www.mercurydata.app). This is a data indexer, running on both Testnet and Mainnet. The team is developing some bleeding-edge data tools that are beginning to redefine what's possible with network data. One such development is the [ZephyrVM](https://docs.mercurydata.app/zephyr-full-customization/introduction): Mercury's cloud execution environment. In short, Zephyr allows you to write (Rust) programs that will run at the close of _every_ ledger on the Stellar network. Inside that program, you can access any kind of current or past data, interact with external web services, create serverless functions, and populate databases. Similar to Launchtube, we won't be _directly_ interacting with Mercury inside the Ye Olde Guestbook dapp. Those interactions will be handled by the `passkey-kit` package. Also similar to Launchtube, this one takes some setting up. - -The `passkey-kit` package doesn't "ship" with a Zephyr program in the published package, but it _does_ have all the Zephyr goodness you'll need in the source repository. Here's how you get that Zephyr program running on Mercury so you can access the indexed smart wallet events. By the way, these commands are probably best run _outside_ the directory where you're building your guestbook dapp. - -1. Clone the `passkey-kit` repository from GitHub and enter the `zephyr` directory within it: +:::warning Never expose the relayer key client-side - ```shell - git clone https://github.com/kalepail/passkey-kit - cd passkey-kit/zephyr - ``` +Calls to the Channels relayer authenticate with the API key in an `Authorization: Bearer` header. Put that call in the browser and you've handed the key to anyone who opens their network tab, and they can then spend your credits on whatever they like. This tutorial keeps the key server-side by routing every submission through a same-origin `/api/send` route, which we'll build on the [next page](./setup-passkeys.mdx). -2. Get an authentication token from the Mercury website. You can login to the [Testnet dashboard](https://test.mercurydata.app) here. Click on the **Get access token** button under the "Active subscriptions" section. You'll be given a JWT which will be valid for the next seven days. +::: - ![Mercury Data JWT Token](/assets/guestbook/mercury_token.png) +## What you no longer have to set up - Copy/paste this token into the `.env` file: +Two things that used to live on this page are now handled for you. It's worth knowing _who_ is doing that work, though, because "I don't configure it" is not the same as "it isn't happening." - ```shell - PRIVATE_MERCURY_JWT= - ``` +### Funding new wallets -3. (Optionally) You can get a [long-lasting authentication token](https://docs.mercurydata.app/get-started-with-mercury/authentication) for your account using this token, and making a request to Mercury's API: +[Friendbot](../../../networks/README.mdx#friendbot) still only funds classic `G...` accounts, so it can't top up a `C...` contract address directly. The old workaround was a funder account of your own: you'd generate a keypair, park some Testnet XLM in it, and run an `/api/fund` endpoint that transferred lumens to each new smart wallet. - ```shell - curl -X POST https://api.mercurydata.app/v2/key \ - -H "authorization: Bearer - ``` +Smart Account Kit does this dance internally now. Ask it to fund a wallet and it creates a temporary account, funds _that_ from Friendbot, and transfers the XLM on to your user's contract address. So there's no funder keypair to generate and no secret key in your `.env`. We'll see the two places this shows up (at signup, and behind a "Fund Wallet" button) on the [frontend page](./frontend.mdx). - This will give you an API key that can also be added to your `.env` file. The benefit of this API key is that it will not expire until you generate another API key. +### The reverse-lookup indexer - :::info +When a returning user taps their passkey, your dapp gets a credential ID back from the authenticator. It still needs to turn that credential ID into the smart account contract address the credential was added to, and that reverse lookup still requires indexed network data. That requirement did not go away with Launchtube. - For this tutorial, you'll only need one of these. You can specify the JWT **or** the API key, and get things working exactly the same. In the `PasskeyServer`, though, make sure you specify the corresponding value. +What changed is that you no longer build the indexer yourself. Smart Account Kit ships an indexer client that defaults to Mercury's hosted `smart-account-indexer` for Testnet and Mainnet. Its read endpoints are public, so there's no JWT to fetch and no Zephyr program to compile and deploy. You can point `indexerUrl` at any wire-compatible provider, or pass `indexerUrl: false` to switch discovery off entirely. - ::: +:::note -4. Compile and deploy the event indexer Zephyr program to the Testnet network. +This is a boundary that's easy to blur when migrating an older passkey app, so to be explicit about it: the OpenZeppelin Relayer replaced Launchtube's **submission and fee-sponsorship** role. It did _not_ replace Mercury's **indexing and reverse-lookup** role. If you rip out your Launchtube code and your indexer configuration in the same commit, you'll break returning-user login on any device that hasn't seen that passkey before. - ```shell - cargo install mercury-cli - export MERCURY_JWT="" - # Make sure you're using Rust version 1.79.0 or newer - mercury-cli --jwt $MERCURY_JWT --local false --mainnet false deploy - ``` - -If everything succeeds, you're ready to go! Well done! +::: -You're now ready to dive into the actual passkey implementation and get your users authenticated with the guestbook dapp! Let's get to it! +The other half of this story is local: we'll configure the kit with `IndexedDBStorage` so a connected session survives a page reload. That's a per-browser cache sitting in front of the indexer, not a substitute for it. ## Troubleshooting -It's possible something has gone wrong during your execution of the processes above. Here are some general suggestions of fixes or things you can try if something goes wrong with your use of Launchtube or Mercury: +Things that can trip you up while getting this wired up: -1. **Generate a new Launchtube token.** It's possible the Launchtube token you're using has run out of credits. Since we're using Testnet for this tutorial, there's no harm in generating a brand new token any time by visiting `https://testnet.launchtube.xyz/gen` in your browser. -2. **Make sure your Zephyr program successfully deployed.** I've been stuck more than once with a not-working Mercury request because the Zephyr program hadn't actually deployed successfully. Make sure the `mercury-cli deploy` command's output doesn't have any errors in it. -3. **Check the [Mercury documentation](https://docs.mercurydata.app).** It's quite good and can help you get past a lot of the hurdles you might face. +1. **The relayer returns a 401 or a 403.** Generate a fresh key at [https://channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen). Testnet keys are free to re-issue, so there's no harm in doing this whenever you're suspicious of one. +2. **Your `PRIVATE_*` variables come back undefined.** SvelteKit reads `.env` when the dev server starts. If you edited the file while `pnpm dev` was running, restart it. +3. **Login can't find a wallet you know you created.** You're probably in a different browser or profile than the one you signed up in, with an empty IndexedDB, which means you're exercising the indexer path. Give it a few ledgers to catch up on a freshly deployed wallet, and check your browser console for a failed request to the indexer. -In any case, feel free to ask questions or drop a chat in the [`#passkeys`](https://discord.com/channels/897514728459468821/1250851135561142423) and [`#launchtube`](https://discord.com/channels/897514728459468821/1293204627361108141) channels in the Stellar Developer Discord server. There's usually somebody around who's ready and willing to help out! +If you get stuck, drop a question in the [`#passkeys`](https://discord.com/channels/897514728459468821/1250851135561142423) channel on the [Stellar Developer Discord](https://discord.gg/stellardev). There's usually somebody around who's ready and willing to help out! diff --git a/docs/build/apps/guestbook/setup-passkeys.mdx b/docs/build/apps/guestbook/setup-passkeys.mdx index 1e56bf4a0c..b8ed7e40bc 100644 --- a/docs/build/apps/guestbook/setup-passkeys.mdx +++ b/docs/build/apps/guestbook/setup-passkeys.mdx @@ -3,251 +3,267 @@ title: Setup Passkeys sidebar_position: 50 --- -Now, we've got the requisite accounts, tokens, etc. created, and we're ready to start putting the `passkey-kit` to work, getting our users connected! +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: -## Passkey client +- `src/lib/smartAccountClient.ts` is the browser-facing piece. It configures the kit and exports an `account` that the rest of the app uses for everything. +- `src/routes/api/send/+server.ts` is 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. -We'll start by creating an instance of the `PasskeyKit` class. We'll call it `account`, and this `account` will be the **primary** point of interaction for the dapp and the user's passkey. Every transaction will be signed using `account.sign()`, users will signup with `account.createWallet()`, users will login with `account.connectWallet()`. This `account` is a pretty tough workhorse! Let's make it happen. +That's genuinely it. Compared to the Launchtube-and-Mercury era, a _lot_ of scaffolding has collapsed into the kit itself. -We're creating this in `src/lib/passkeyClient.ts` so it's available to us in the rest of our frontend codebase. The [`$lib` import alias](https://svelte.dev/docs/kit/$lib) is a SvelteKit thing, but the important thing is we want this file (and its exports) to be available throughout **all** of our frontend files. How you make that happen for other frameworks is an exercise left to the reader. +## 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](https://svelte.dev/docs/kit/$lib) 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. + +```ts title="src/lib/smartAccountClient.ts" +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"; -```js title="src/lib/passkeyClient.ts" -import { PasskeyKit } from "passkey-kit"; import { PUBLIC_STELLAR_RPC_URL, PUBLIC_STELLAR_NETWORK_PASSPHRASE, - PUBLIC_WALLET_WASM_HASH, + PUBLIC_ACCOUNT_WASM_HASH, + PUBLIC_WEBAUTHN_VERIFIER_ADDRESS, + PUBLIC_NATIVE_TOKEN_CONTRACT, } from "$env/static/public"; -export const account = new PasskeyKit({ +/** + * 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, - walletWasmHash: PUBLIC_WALLET_WASM_HASH, + 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, }); ``` -The `PUBLIC_WALLET_WASM_HASH` variable is the Wasm hash of the smart wallet's contract code. This Wasm hash identifies the executable code that will be deployed for new smart wallets and is simply the Sha256 hash of the compiled contract executable file. This hash is returned during when a compiled contract is installed on the network. - -That's all there is to it! This `account` will be fully ready to authenticate users and sign transactions! (It's even easier than all the prerequisites isn't it!) - -Now, we've also added some useful "helpers" into the `$lib/passkeyClient.ts` file in our template. The [source code file](https://github.com/ElliotFriend/soroban-template-sveltekit-passkeys/blob/main/src/lib/passkeyClient.ts) is commented to reflect what these helpers are, and how they work. These are strictly for convenience, though. You could stop right here and come away with perfectly valid signed passkey transactions. These helpers are: - -- A configured instance of the `rpc.Server` class so we can make RPC requests without having to know/import the RPC's URL all the time. - - ```js title="src/lib/passkeyClient.ts" - import { Server } from "@stellar/stellar-sdk/rpc"; - - /** - * A configured Stellar RPC server instance used to interact with the network - */ - export const rpc = new Server(PUBLIC_STELLAR_RPC_URL); - ``` - -- A SAC client to interact with the native XLM asset contract. We're making an assumption that native lumens is a "good enough" asset interaction to get the tutorial working, and for playing on Testnet. You could easily export _another_ SAC client to interact with USDC, for example. The native contract address can be obtained from Stellar-CLI with the command `stellar contract id asset --asset native`. - - ```js title="src/lib/passkeyClient.ts" - import { SACClient } from "passkey-kit"; - import { PUBLIC_NATIVE_CONTRACT_ADDRESS } from "$env/static/public"; +Worth calling out a few of those options: - /** - * A client allowing us to easily create SAC clients for any asset on the - * network. - */ - const sac = new SACClient({ - rpcUrl: PUBLIC_STELLAR_RPC_URL, - networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE, - }); +- **`accountWasmHash`** is the Wasm hash of the [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/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. +- **`webauthnVerifierAddress`** is the contract address of the shared verifier that checks `secp256r1` signatures on-chain. Also pre-filled. +- **`relayerUrl`** points at our _own_ `/api/send` route rather than at OpenZeppelin directly. That's the whole trick for keeping the API key server-side, and we'll build that route below. +- **`storage`** decides where a connected session lives. `IndexedDBStorage` means a session survives a page reload. As noted in the [prerequisites](./passkeys-prerequisites.mdx), this is a per-browser cache sitting in front of the indexer, not a replacement for it. +- **`rpName`** is the "relying party" name, which is the label your users will see in their authenticator's prompt. Make it something they'll recognize. - /** - * A SAC client for the native XLM asset. - */ - export const native = sac.getSACClient(PUBLIC_NATIVE_CONTRACT_ADDRESS); - ``` +:::note -## Passkey server +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. -So, that's the client-facing passkey code (and some helpers) taken care of. What about the server-side, where we want to be cautious about leaking secrets and tokens?! +::: -We're setting this up in `src/lib/server/passkeyServer.ts`, for similar reasons we listed above. This gives us an importable `server` instance that can be accessed and used in other server-side logic. Svelte gives us the added benefit of [keeping the code in this directory safe](https://svelte.dev/docs/kit/server-only-modules#Your-modules). When we want to safeguard credentials and secrets, we can put any sensitive code in the `$lib/server` directory. +### A couple of helpers -```js title="src/lib/server/passkeyServer.ts" -import { PasskeyServer } from "passkey-kit"; +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. -import { - PUBLIC_LAUNCHTUBE_URL, - PUBLIC_MERCURY_URL, - PUBLIC_STELLAR_RPC_URL, -} from "$env/static/public"; -import { - PRIVATE_LAUNCHTUBE_JWT, - PRIVATE_MERCURY_JWT, -} from "$env/static/private"; +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. -export const server = new PasskeyServer({ - rpcUrl: PUBLIC_STELLAR_RPC_URL, - launchtubeUrl: PUBLIC_LAUNCHTUBE_URL, - launchtubeJwt: PRIVATE_LAUNCHTUBE_JWT, - mercuryUrl: PUBLIC_MERCURY_URL, - mercuryJwt: PRIVATE_MERCURY_JWT, - // mercuryKey: PRIVATE_MERCURY_KEY, // optionally -}); +```ts title="src/lib/smartAccountClient.ts" +/** + * 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 { + 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; +} ``` -And you're done with the `PasskeyServer`! Well done! +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: -This `server` instance will be used for sending transactions (via Launchtube) and reverse-looking-up contract addresses from a known passkey ID (via Mercury). +```ts title="src/lib/smartAccountClient.ts" +/** + * 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"; +} +``` -### API routes +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." -Now, we'll need a way to utilize some of the functionality of this `server` from the client without exposing any of the sensitive information. For that, we'll set up a collection of (SvelteKit) routes to act as a backend, and _those routes_ (not the client-side code) will make use of the `server` instance. These files live in `src/routes/api/*` in the project repo. +## Tracking the connected wallet -Some of the structure here is a bit Svelte-specific, but it should pretty easily make sense enough to non-Svelte developers regardless. The _one_ SvelteKit-specific thing to note is any file named `*server.{ts,svelte}` will **only** run [on the server](https://svelte.dev/docs/kit/routing#server). Your secrets, tokens, credentials, etc. are considered safe to use within these files. +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: -#### `/api/send` +```ts title="src/lib/state/UserState.svelte.ts" +import { account } from "$lib/smartAccountClient"; -This API endpoint will send a transaction to the network, via Launchtube. It receives a `POST` request, whose `body` object contains a base64-encoded transaction. +class Wallet { + contractAddress: string | null = $state(null); -:::warning + constructor() { + account.events.on( + "walletConnected", + ({ contractId }) => (this.contractAddress = contractId), + ); + account.events.on( + "walletDisconnected", + () => (this.contractAddress = null), + ); + } +} -If you're creating a `yourdomain.com/api/send` method, you will probably need to do "something" to ensure that only the right "kinds" of transactions are actually sent to the network. I.e., make sure it's coming from your dapp, your users, etc. Otherwise, it would be possible for a bad actor to discover they could use this to send their own transactions, while you pick up the tab for the fees! +export const wallet = new Wallet(); +``` -The implementation of this is outside the scope of this tutorial, but be sure to consider these kinds of risks as you prepare for a more production-level deployment. +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 -```js title="src/routes/api/send/+server.ts" -import { server } from "$lib/server/passkeyServer"; -import { json } from "@sveltejs/kit"; -import type { RequestHandler } from "./$types"; +Now for the server side, where we need to be careful about leaking credentials. -export const POST: RequestHandler = async ({ request }) => { - const { xdr } = await request.json(); - const res = await server.send(xdr); - return json(res); -}; -``` +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. -#### `/api/contract/[signer]` +In SvelteKit, any file named `+server.ts` runs [only on the server](https://svelte.dev/docs/kit/routing#server), and anything under `$lib/server` [can't be imported into client code at all](https://svelte.dev/docs/kit/server-only-modules#Your-modules). Those are the places your secrets are safe. Some of this is SvelteKit-specific, but every full-stack framework has an equivalent seam. -This endpoint will reverse-lookup (via Mercury) a contract address given a passkey ID. The path parameter `[signer]` is how we'll give the passkey ID to the API `GET` request. +The kit sends one of two payload shapes, and Channels accepts both: -```js title="src/routes/api/contract/[signer]/+server.ts" +- `{ 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. -import { server } from '$lib/server/passkeyServer'; -import type { RequestHandler } from './$types'; +You must never mix the two in one request, so the route validates that before forwarding. -export const GET: RequestHandler = async ({ params }) => { - const contractId = await server.getContractId(params.signer!); +```ts title="src/routes/api/send/+server.ts" +import type { RequestHandler } from "./$types"; +import { error, json } from "@sveltejs/kit"; - return new Response(String(contractId)); -}; -``` +import { + PRIVATE_RELAYER_BASE_URL, + PRIVATE_RELAYER_API_KEY, +} from "$env/static/private"; -#### `/api/fund/[address]` - -This is another helper, but on the API side of things! [Friendbot](../../../networks/README.mdx#friendbot) doesn't support `C...` addresses for Testnet funding. So, we're setting up an endpoint so we can add some funds to the dapp users' wallets. This gives them some tokens to play around with, and allows _us_ to receive those guestbook donations! - -This API endpoint is not strictly necessary. But, it is a useful way to see how these kinds of interactions can occur between a "regular" `G...` address and a soroban contract `C...` address. - -```js title="src/routes/api/fund/[address]/+server.ts" - -import { error, json } from '@sveltejs/kit'; -import { PRIVATE_FUNDER_SECRET_KEY } from '$env/static/private'; -import { native } from '$lib/passkeyClient'; -import type { RequestHandler } from './$types'; -import { Keypair } from '@stellar/stellar-sdk'; -import { basicNodeSigner } from '@stellar/stellar-sdk/contract'; -import { PUBLIC_STELLAR_NETWORK_PASSPHRASE } from '$env/static/public'; - -export const GET: RequestHandler = async ({ params, fetch }) => { - const fundKeypair = Keypair.fromSecret(PRIVATE_FUNDER_SECRET_KEY); - const fundSigner = basicNodeSigner(fundKeypair, PUBLIC_STELLAR_NETWORK_PASSPHRASE); - - try { - const { built, ...transfer } = await native.transfer({ - from: fundKeypair.publicKey(), - to: params.address, - amount: BigInt(25 * 10_000_000), - }); - - await transfer.signAuthEntries({ - publicKey: fundKeypair.publicKey(), - signAuthEntry: (auth) => fundSigner.signAuthEntry(auth), - }); - - await fetch('/api/send', { - method: 'POST', - body: JSON.stringify({ - xdr: built!.toXDR(), - }), - }); - - return json({ - status: 200, - message: 'Smart wallet successfully funded', - }); - } catch (err) { - console.error(err); - error(500, { - message: 'Error when funding smart wallet', - }); - } +/** + * 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", + }); + } }; ``` -### Passkey client helpers +A few things this route is doing on purpose: -Each of those API endpoints receives a corresponding function in the `$lib/passkeyClient.ts` file, just to make it a little easier on the client-side to make use of the API routes we just made. +- **The `origin` check** is a first, cheap line of defense. Without it, anyone who finds `yourdomain.com/api/send` can submit their own transactions while _you_ pick up the tab for the fees. +- **The `params` wrapper** 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](../../../tools/openzeppelin-relayer.mdx), 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. -This allows us to write the `fetch` code once, and use it consistently everywhere else. They're pretty straightforward and don't really need much explanation. We'll add them to the end of the file: +:::warning -```js title="src/lib/passkeyClient.ts" -/** - * A wrapper function so it's easier for our client-side code to access the - * `/api/send` endpoint we have created. - * - * @param xdr - The base64-encoded, signed transaction. This transaction - * **must** contain a Soroban operation - * @returns JSON object containing the RPC's response - */ -export async function send(xdr: string) { - return fetch("/api/send", { - method: "POST", - body: JSON.stringify({ - xdr, - }), - }).then(async (res) => { - if (res.ok) return res.json(); - else throw await res.text(); - }); -} +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. -/** - * A wrapper function so it's easier for our client-side code to access the - * `/api/contract/[signer]` endpoint we have created. - * - * @param signer - The passkey ID we want to find an associated smart wallet for - * @returns The contract address to which the specified signer has been added - */ -export async function getContractId(signer: string) { - return fetch(`/api/contract/${signer}`).then(async (res) => { - if (res.ok) return res.text(); - else throw await res.text(); - }); -} +Implementing all of that is outside the scope of this tutorial, but it's very much inside the scope of shipping to real users. -/** - * A wrapper function so it's easier for our client-side code to access the - * `/api/fund/[address]` endpoint we have created. - * - * @param address - The contract address to fund on the Testnet - */ -export async function fundContract(address: string) { - return fetch(`/api/fund/${address}`).then(async (res) => { - if (res.ok) return res.json(); - else throw await res.text(); - }); -} -``` +::: 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! diff --git a/docs/build/apps/guestbook/smart-contract.mdx b/docs/build/apps/guestbook/smart-contract.mdx index ca68bd8d3c..97b9333ce4 100644 --- a/docs/build/apps/guestbook/smart-contract.mdx +++ b/docs/build/apps/guestbook/smart-contract.mdx @@ -9,15 +9,15 @@ The heart of this project starts with our smart contract. This smart contract wi All the following "business logic" will be handled by our smart contract: -- **A means of writing messages.** Users can invoke this function to leave a message for the site maintainer. They will have to authenticate this function, and it must contain a `title` and `text` field (both strings). The function will return the ID number of the message, which increments sequentially. +- **A means of writing messages.** Users can invoke this function to leave a message for the site maintainer. They will have to authenticate this function, and it must contain a `title` and `text` field (both `String`s). The function will return the `u32` ID number of the message, which increments sequentially. - **A means of reading messages.** This function will allow a user to "query" the contract for a guestbook message, by providing the message ID. Non-existing IDs will result in an error. - **A means of editing messages (with authentication).** If a user needs to modify their previously written message, they can use this function to do so. They must provide proper authorization to do so, and they must provide either a `title` or `text` field (both cannot be empty, but one of them could). -- **A means of retrieving donations and transferring them to the "admin" address.** The hubris associated with requesting donations on a site like this speaks volumes of the maintainer's sense of self. However, providing this functionality is an excellent exercise in asset interactions within the smart contract. +- **A means of retrieving donations and transferring them to the `Admin` address.** The hubris associated with requesting donations on a site like this speaks volumes of the maintainer's sense of self. However, providing this functionality is an excellent exercise in asset interactions within the smart contract. - We'll also need some utility functions that the contract will use internally, as well as a `__constructor` and an `upgrade` function, in case we need to upgrade our smart contract in the future. :::note -We'll be diving into each of the main functions below, but if you want to see the whole smart contract uninterrupted, it can be found here: [https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/lib.rs](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/lib.rs) +We'll be diving into each of the main functions below, but if you want to see the whole smart contract uninterrupted, it can be found [on GitHub](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/lib.rs). ::: @@ -197,7 +197,9 @@ pub fn read_message(env: Env, message_id: u32) -> Result { #### `read_latest` -But, what if someone just wants to read the _latest_ message, and doesn't know what its ID number is? Well, we're providing a function for exactly that. No arguments to pass in. No authentication. Just pull the message from the contract's persistent storage, and return the struct (or panic, if the contract doesn't have any messages yet). Easy peasy. +But, what if someone just wants to read the _latest_ message, and doesn't know what its ID number is? Well, we're providing a function for exactly that. No arguments to pass in. No authentication. Just pull the message from the contract's persistent storage, and return the struct. Thanks to the `__constructor` function, any contract instantiated from this executable will **always** have at least the first message. So, we don't even need to panic if there's no message to retrieve, because that won't happen. + +Easy peasy. ```rust /// Read the latest message to be sent to the guestbook. @@ -222,7 +224,9 @@ We'll set aside whether or not the maintainer of the guestbook _should_ be solic The `claim_donations` function will allow the invoker of the function to send a balance of any token to the admin of the guestbook contract. We'll direct your attention to two aspects of this function, in particular. -First, we're requiring an `Address` for the token that should be claimed. It may be your first instinct to hard-code and default to native XLM for these donations. This can certainly be done, but the address for that contract will be different on Mainnet, Testnet, or Futurenet, and the contract would have to be modified and re-compiled for each network you want to deploy to. A more "universally" applicable approach is to take the token address as an argument to this function, and allow the donors and admin to use whichever token they deem suitable for the situation. +First, we're requiring an `Address` for the token that should be claimed. It may be your first instinct to hard-code and default to native XLM for these donations. This can certainly be done, but the address for that contract will be different on Mainnet, Testnet, or Futurenet, and the contract would have to be modified and re-compiled for each network you want to deploy to. Or, we could pass in a contract address in the `__constructor` function, and pull donations for that asset later on. But, what if someone wants to donate with some other token? Say, `USDC` or `KALE`. + +A more "universally" applicable approach is to take the token address as an argument to this function, and allow the donors and admin to use whichever token they deem suitable for the situation. Second, we're not requiring any authentication for this function. It's not really necessary to add that logic into the mix, because no real harm will come if a non-admin invokes the function: @@ -300,7 +304,7 @@ fn get_message(env: &Env, message_id: u32) -> Message { #### `save_message` -We're abstracting away the method we're using to write a message to the contract storage because it's used in two places: the `initialize` and `write_message` functions. We want both to store messages in the same manner, so we're enforcing that by using this utility function. +We're abstracting away the method we're using to write a message to the contract storage because it's used in two places: the `__constructor` and `write_message` functions. We want both to store messages in the same manner, so we're enforcing that by using this utility function. We're storing a `MessageCount` in the contract's instance storage, to assist us in message saves, reads, edits, etc. This could certainly be done differently, but it will be convenient for us when it comes to saving new messages, reading messages from the contract, querying for contract state in the frontend, etc. @@ -346,15 +350,15 @@ pub struct Message { #### `DataKey` -This is a struct that's used elsewhere in the contract to define the keys for the various storage entries the contract will hold. Nothing groundbreaking or remarkable here, to be honest, but it's still worth showing. The `Message(ID_NUMBER)` will be used as the key to store a `Message` struct on-chain as the corresponding value. +This is an `enum` that's used elsewhere in the contract to define the keys for the various storage entries the contract will hold. Nothing groundbreaking or remarkable here, to be honest, but it's still worth showing. The `Message(u32)` variant will be used as the key to store a `Message` struct on-chain as the corresponding value. ```rust #[contracttype] #[derive(Clone)] pub enum DataKey { - Admin, - MessageCount, - Message(u32), + Admin, // : address + MessageCount, // : u32 + Message(u32), // : Message } ``` diff --git a/docs/build/guides/contract-accounts/advanced-patterns.mdx b/docs/build/guides/contract-accounts/advanced-patterns.mdx index e8fc4fd586..eec438032a 100644 --- a/docs/build/guides/contract-accounts/advanced-patterns.mdx +++ b/docs/build/guides/contract-accounts/advanced-patterns.mdx @@ -45,5 +45,6 @@ See the [Complex Account example](../../smart-contracts/example-contracts/comple - See these patterns applied in the [contract account examples](./examples.mdx). - Explore reference implementations and libraries: - - [OpenZeppelin Stellar contracts](https://github.com/OpenZeppelin/stellar-contracts) + - [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account): modular signer/policy framework in [OpenZeppelin Stellar contracts](https://github.com/OpenZeppelin/stellar-contracts) + - [Smart Account Kit](https://github.com/stellar/smart-account-kit): TypeScript SDK for deploying and signing with smart accounts - [Crossmint smart account](https://github.com/Crossmint/stellar-smart-account) diff --git a/docs/build/guides/contract-accounts/smart-wallets.mdx b/docs/build/guides/contract-accounts/smart-wallets.mdx index 966db466ae..9aa42d7767 100644 --- a/docs/build/guides/contract-accounts/smart-wallets.mdx +++ b/docs/build/guides/contract-accounts/smart-wallets.mdx @@ -40,11 +40,22 @@ Benefits: ## Tooling -- **Passkey Kit**: TypeScript SDK for creating contract accounts and signing with passkeys. - - Demo: [passkey-kit-demo.pages.dev](https://passkey-kit-demo.pages.dev) - - Code: [github.com/kalepail/passkey-kit](https://github.com/kalepail/passkey-kit) -- **Launchtube**: relay for submitting transactions and handling fees/sequence numbers. - - Code: [github.com/stellar/launchtube](https://github.com/stellar/launchtube) +The recommended building blocks for new smart wallet projects: + +- **OpenZeppelin Smart Account**: audited, modular smart account framework for Stellar. Separates signers, context rules, and policies (multisig, spending limits) so you can mix and match authorization logic without rewriting the account. + - Docs: [docs.openzeppelin.com/stellar-contracts/accounts/smart-account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account) + - Code: [github.com/OpenZeppelin/stellar-contracts](https://github.com/OpenZeppelin/stellar-contracts) +- **Smart Account Kit**: TypeScript SDK for deploying and interacting with smart accounts (passkey/WebAuthn flows, Ed25519, policy signers). Successor to `passkey-kit`. + - Code: [github.com/stellar/smart-account-kit](https://github.com/stellar/smart-account-kit) +- **OpenZeppelin Relayer**: relayer for submitting transactions and managing fees, channel accounts, and sequence numbers. Replaces Launchtube for most new projects. + - Stellar Channels guide: [docs.openzeppelin.com/relayer/guides/stellar-channels-guide](https://docs.openzeppelin.com/relayer/guides/stellar-channels-guide) + - Code: [github.com/OpenZeppelin/openzeppelin-relayer](https://github.com/OpenZeppelin/openzeppelin-relayer) + +Predecessor tooling, referenced by older tutorials and demos: + +- **Passkey Kit**: the original TypeScript SDK for passkey smart wallets, succeeded by Smart Account Kit. + - Code: [github.com/stellar/passkey-kit](https://github.com/stellar/passkey-kit) +- **Launchtube**: the earlier Stellar-hosted relay for passkey transactions. The [`stellar/launchtube`](https://github.com/stellar/launchtube) repository is archived and the hosted service has been retired; use OpenZeppelin Relayer instead. ## Get involved diff --git a/static/assets/guestbook/mercury_token.png b/static/assets/guestbook/mercury_token.png deleted file mode 100644 index 114188e9f6..0000000000 Binary files a/static/assets/guestbook/mercury_token.png and /dev/null differ