diff --git a/docs/build/apps/guestbook/README.mdx b/docs/build/apps/guestbook/README.mdx index dc7f6f204f..902b7da868 100644 --- a/docs/build/apps/guestbook/README.mdx +++ b/docs/build/apps/guestbook/README.mdx @@ -5,6 +5,6 @@ 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, using [Smart Account Kit](https://github.com/stellar/smart-account-kit), the [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account), and the [OpenZeppelin Relayer (Stellar Channels plugin)](https://docs.openzeppelin.com/relayer/1.4.x/guides/stellar-channels-guide). diff --git a/docs/build/apps/guestbook/bindings.mdx b/docs/build/apps/guestbook/bindings.mdx index 13ba71b1b6..11854719df 100644 --- a/docs/build/apps/guestbook/bindings.mdx +++ b/docs/build/apps/guestbook/bindings.mdx @@ -84,27 +84,28 @@ We're straying just a _bit_ into the Svelte-ish side of things here. The main go 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 - -// 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 +```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"; + +// `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 -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: +That was a lot of steps and a lot of work wasn't it!? The good news is that the guestbook repo ships with an `initialize.js` script that performs all of those actions for you. This script will: - Create and fund a keypair in the CLI - Install and deploy **all contracts** in the `/contracts` directory - Generate bindings from the deployed contracts - 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. See the [source code in the guestbook repo](https://github.com/AshFrancis/ye-olde-guestbook/blob/main/initialize.js) (which is documented with comments), or the [officially maintained version](https://github.com/stellar/soroban-template-astro/blob/main/initialize.js) in the [`soroban-template-astro` repository](https://github.com/stellar/soroban-template-astro). Run the initialization script like so: @@ -124,6 +125,6 @@ We've also added a command to the `package.json` scripts, so you can run this in 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 prerequisites for our passkey-powered smart wallets — the OpenZeppelin Relayer API key and a funder account. diff --git a/docs/build/apps/guestbook/frontend.mdx b/docs/build/apps/guestbook/frontend.mdx index 978c2ff44e..7297437eb3 100644 --- a/docs/build/apps/guestbook/frontend.mdx +++ b/docs/build/apps/guestbook/frontend.mdx @@ -7,348 +7,320 @@ 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 the user's smart-account contract address and credential ID in a little store. 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/Login buttons (when logged out) or a Settings popover (when logged in). It also silently reconnects on page load if we have a saved credential ID: -The buttons themselves are simple enough: +```svelte title="src/lib/components/ConnectButtons/ConnectButtons.svelte" + -```html title="src/lib/components/connectButtons.svelte"
- - - + {#if !user.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. - -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. +For signup, we call `account.createWallet(appName, userName, { autoSubmit: true })`. Under the hood, Smart Account Kit: -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. +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, and +3. Because `autoSubmit: true`, submits the deploy transaction through our `/api/relay` proxy. -```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"; +Once the wallet is deployed we call `fundContract(contractId)` to airdrop 25 Testnet XLM into it. -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 -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. - -```js title="src/lib/components/connectButtons.svelte" -import { getContractId } from "$lib/passkeyClient"; - -async function login() { - console.log("logging in"); - try { - // The connectWallet function requires us to pass a function that can - // be used to reverse-lookup the smart wallet address, provided we know - // the passkey's ID (the user supplies that during the function's execution) - const { keyId_base64, contractId: cid } = await account.connectWallet({ - getContractId, - }); +For returning users, `account.connectWallet({ prompt: true })` prompts the browser's passkey picker, and Smart Account Kit uses the selected credential to look up the corresponding smart-account contract in IndexedDB. - // 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" + ``` -Similar, yet simpler, when compared with our `signup` function. We're using the `account.connectWallet` function. This function will: +:::info -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. +No reverse-lookup indexer is needed for login. Earlier iterations of this tutorial used Mercury/Zephyr to resolve a passkey credential to a contract address; Smart Account Kit instead keeps that mapping in the browser's IndexedDB, which is written whenever a wallet is created or connected. -Great! Let's get the user logged out when they need to. +::: #### User logout -This is quite a bit easier than either signup or login functions. We don't really need to communicate with the Stellar network or Mercury here. All we'll do is clear out the user state, essentially. - -```js title="src/lib/components/connectButtons.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(""); +Logout is just clearing local state: - // 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", - }); - } -} +```ts title="src/lib/state/UserState.svelte.ts" +reset = () => { + this.keyId = null; + this.contractAddress = null; + window.localStorage.clear(); + window.location.reload(); +}; ``` -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. - -However, we will look into the `donate` function here. This is a really useful example of how a dapp can enable their smart wallet users to interact with any asset on the Stellar network. (Here, we are using Testnet XLM for our asset, but the flow would be identical for _any_ asset you may want to use.) - -The button is still pretty simple, just like the authentication buttons. We are adding some "loading" logic for when the transaction is taking place, though. So, it's got a _few_ more bells and whistles. - -```html title="src/lib/components/connectButtons.svelte" - - - -``` - -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; - } +When a user is logged in, the Settings popover shows their balance, their contract address, and buttons for funding, donating, and logging out. Two flows here are worth a look: reading the balance (no signing), and sending a donation (the first user-signed transaction in the tutorial). + +#### Reading the balance + +The SAC stores each holder's balance in a `ContractData` ledger entry keyed by `['Balance', ownerAddress]`. We read it directly with `rpc.getLedgerEntries` — no simulation, no signing, and importantly no `contract.Client.from()` (whose SAC-spec round-trip trips the browser bundle). + +```ts title="src/lib/components/ConnectButtons/Settings.svelte" +import { rpc } from "$lib/passkeyClient"; +import { Address, Asset, xdr, scValToNative } from "@stellar/stellar-sdk"; +import { PUBLIC_STELLAR_NETWORK_PASSPHRASE } from "$env/static/public"; + +async function getBalance() { + const nativeContractId = Asset.native().contractId( + PUBLIC_STELLAR_NETWORK_PASSPHRASE, + ); + const ownerScVal = new Address(user.contractAddress!).toScVal(); + const balanceKey = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(nativeContractId).toScAddress(), + key: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Balance"), ownerScVal]), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + + const { entries } = await rpc.getLedgerEntries(balanceKey); + if (entries.length === 0) return "0"; + const data = entries[0].val.contractData().val(); + const record = scValToNative(data) as { amount?: bigint }; + return (record.amount ?? 0n).toString(); } ``` -:::info +A freshly-deployed wallet with no transfers yet has no `Balance(...)` entry at all, which is why we handle `entries.length === 0` as "balance is zero." -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. +#### Sending a donation -::: +The donate button is a simple dialog with a number input. The interesting piece is `sendDonation`: we build the SAC `transfer(from, to, amount)` call directly with `contract.AssembledTransaction.build()`, passing explicit `ScVal` args so we don't need the SAC spec. Then `send(at)` hands the transaction to Smart Account Kit's `signAndSubmit`, which runs the passkey ceremony and submits through `/api/relay`. -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. +```ts title="src/lib/components/ConnectButtons/DonateButton.svelte" +import { SAK_DEPLOYER_PUBLIC_KEY, send } from "$lib/passkeyClient"; +import { networks } from "ye_olde_guestbook"; +import { user } from "$lib/state/UserState.svelte"; +import { Address, Asset, contract, xdr } from "@stellar/stellar-sdk"; +import { + PUBLIC_STELLAR_NETWORK_PASSPHRASE, + PUBLIC_STELLAR_RPC_URL, +} from "$env/static/public"; + +let donation: number | undefined = $state(); + +async function sendDonation() { + if (!donation) throw new Error("undefined donation amount"); + + const nativeContractId = Asset.native().contractId( + PUBLIC_STELLAR_NETWORK_PASSPHRASE, + ); + const amountI128 = xdr.ScVal.scvI128( + new xdr.Int128Parts({ + hi: xdr.Int64.fromString("0"), + lo: xdr.Uint64.fromString( + BigInt(Math.round(donation * 10_000_000)).toString(), + ), + }), + ); + + const at = await contract.AssembledTransaction.build({ + contractId: nativeContractId, + method: "transfer", + args: [ + new Address(user.contractAddress!).toScVal(), // from: smart account + new Address(networks.testnet.contractId).toScVal(), // to: guestbook + amountI128, + ], + rpcUrl: PUBLIC_STELLAR_RPC_URL, + networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE, + // G-address stand-in for the source — stellar-sdk's Account rejects + // C-addresses. signAndSubmit re-signs with its own deployer before + // submitting, so this only affects the initial simulation. + publicKey: SAK_DEPLOYER_PUBLIC_KEY, + timeoutInSeconds: 60, + parseResultXdr: (result: xdr.ScVal) => result, + }); + + await send(at); +} +``` -Enough of the account and asset things, let's get to the guestbook entries! +The `from` argument is the user's smart account `C...` address — that's what the SAC checks against when enforcing authorization, via the smart account's passkey-signed auth entry. `send()` hands the assembled transaction to Smart Account Kit's `signAndSubmit`, which signs the auth entry, re-simulates, and submits via `/api/relay` → OZ Channels. ## Sign the guestbook -First, we'll need a page that allows us to actually _sign_ the guestbook. We'll have a form that takes a `title` and `message` field, and then we'll submit the transaction with the `send` helper function, just like we did with the XLM transfer previously. - -The form is pretty simple, and it's barely worth mentioning. We have a text input, a textarea input, and a button. Some checks are performed to see if the button should be enabled (if a user is not logged in, for example). Otherwise, it's pretty unremarkable: - -```html title="src/routes/sign/+page.svelte" - +Now the main event: writing a guestbook entry. The page has a simple form with title + message inputs: +```svelte title="src/routes/sign/+page.svelte" - ``` -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). +The `signGuestbook` function is where the contract call happens. It looks identical in shape to the `sendDonation` function — just pointed at a different contract: -```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'; +```ts title="src/routes/sign/+page.svelte" +import { goto } from "$app/navigation"; +import { resolve } from "$app/paths"; +import ye_olde_guestbook from "$lib/contracts/ye_olde_guestbook"; +import { send } from "$lib/passkeyClient"; +import { user } from "$lib/state/UserState.svelte"; 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 { + isLoading = true; + const at = await ye_olde_guestbook.write_message({ + author: user.contractAddress!, + title: messageTitle, + text: messageText, + }); + + await send(at); + + // The AssembledTransaction's result is a Rust-flavored Result + // from the contract bindings. Unwrap it to get the message id. + const messageId = at.result.unwrap(); + + goto(resolve(`/read/${messageId}`)); + } catch (err) { + console.error(err); + } finally { + isLoading = false; + } } ``` -The heart and soul of this function is to invoke the `write_message` function from our contract. Thanks to our generated bindings, that's really easily done. - -We get the message ID as the return value, and then redirect the user to the page where they can read _that_ particular entry. +The `write_message` function comes from our generated contract bindings. Thanks to those bindings, invoking a Soroban function looks like any other typed TypeScript call. -How does this page read the guestbook entry? Excellent timing for that question! +The contract returns the message ID, and we redirect the user to the page for that particular entry. ## Read guestbook entries ### Read a single entry -The first page we'll create is one that reads and displays a single guestbook message from the smart contract storage. We'll use a server-side function for this. That way, if we were using a paid RPC provider, we could have this function run on the server and return the relevant data to the client. - -:::info - -This `+page.server.ts` is a Svelte way of saying "every time this page is requested by a user, run this function on the server, and give the data to the client." - -The `[id]` part of the filename tells this route that we expect to have a path-based parameter, and we can use it as `id`. - -::: +We use a server-side `load` function so this 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), }); @@ -357,245 +329,71 @@ export const load: PageServerLoad = async ({ params }) => { message: result.unwrap(), }; } catch (err) { - error(500, { - message: - "Sorry, something went wrong. Most likely, the message you're looking for doesn't exist.", - }); + throw error( + 500, + "Sorry, something went wrong. Most likely, the message you're looking for doesn't exist.", + ); } }; ``` -You can see here we're using one of the contract functions, `read_message` to get the data. This is a "read-only" function, meaning that no on-chain state is modified when it's run. So, we can just simulate the invocation, which is already done for you when the bindings-generated function is called, and just take the data from the simulation response! Pretty neat, right?! +`read_message` is a read-only function, so we can return the simulation result directly — no signing or submission needed. -We pass the resulting message details back to the page, where it will be displayed. +The page component displays the message using a shared `GuestbookMessage` component: -```html title="src/routes/read/[id]/+page.svelte" +```svelte 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 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: - -```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(), - }; -}; -``` - -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; -} +For the "list all entries" page, we keep the query logic server-side in `src/lib/server/getLedgerEntries.ts`. The implementation uses `rpc.getLedgerEntries()` to fetch the contract's instance storage (which holds the message count) and then a batched fetch for every message ID. See the [repository source](https://github.com/AshFrancis/ye-olde-guestbook/blob/main/src/lib/server/getLedgerEntries.ts) for the full implementation. -// 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'); +## Edit a guestbook entry - return messageCount![0].val().value() as number; -} +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, send, done. -// 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)); - } +```ts title="src/lib/components/GuestbookMessage.svelte" +import ye_olde_guestbook from "$lib/contracts/ye_olde_guestbook"; +import { send } from "$lib/passkeyClient"; - const result = await rpc.getLedgerEntries(...ledgerKeysArray); - const messages = result.entries.map((message) => { - return { - ...scValToNative(message.val.contractData().val()), - }; +const submitEdit = async () => { + isLoading = true; + try { + const at = await ye_olde_guestbook.edit_message({ + message_id: messageId, + title: messageTitle, + text: messageText, }); - return messages; -} -``` + await send(at); -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 -
-
- - -
- -{#each messages as message, i (message.ledger)} - -{/each} -``` - -We're loading the data we retrieve from the server. We even include a little toggle switch so the user can decide if they want to see newer or older entries first. Then, it's time to display the messages. - -Again, we use the `GuestbookMessage` component. We display one instance of the component for each message entry. - -## Edit a guestbook entry - -If we take a brief look inside the [`GuestbookMessage` component](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. - -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. - -:::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. - -::: - -```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 submitEdit = async () => { - console.log('submitting message edit'); - isLoading = true; - try { - const at = await ye_olde_guestbook.edit_message({ - message_id: messageId, - title: messageTitle, - text: messageText, - }); - - const txn = await account.sign(at.built!, { keyId: $keyId }); - await send(txn.built!); - - toastStore.trigger({ - message: 'Message edited successfully.', - background: 'variant-filled-success', - }); - } catch (err) { - console.log(err); - toastStore.trigger({ - message: 'Something went wrong editing your message. Please try again later.', - background: 'variant-filled-error', - }); - } finally { - editing = false; - isLoading.set(false); - } }; ``` -Notice that, unlike when we signed the guestbook in the first place, we don't have to supply an `author` argument. The smart contract is designed in a way that it looks for the author (and requires authentication) from _within_ its own storage. This ensures that the _original_ author of a guestbook entry is the **only** account authorized to make modifications to it. +Notice we don't pass an `author` argument here. The contract's `edit_message` function reads the author from its own storage and requires authentication from _that_ specific smart account. This ensures the original author is the only account that can modify their entries — not even the guestbook host can change them. -Not even our gracious guestbook host could modify an entry! +That's the full flow. With `send(at)` as a one-liner for "sign with my smart account and submit via the relayer," wiring a Soroban dapp to a smart wallet becomes about as light as it gets. diff --git a/docs/build/apps/guestbook/overview.mdx b/docs/build/apps/guestbook/overview.mdx index 4971cde0ab..d30029a14c 100644 --- a/docs/build/apps/guestbook/overview.mdx +++ b/docs/build/apps/guestbook/overview.mdx @@ -3,9 +3,9 @@ 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. +For this tutorial, we'll walk through the steps as we build a sample application called [Ye Olde Guestbook](https://github.com/AshFrancis/ye-olde-guestbook)[^1], which will be used to showcase various features. :::caution @@ -21,91 +21,83 @@ 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/1.4.x/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. Sensitive credentials (the relayer API key, the funder secret) live 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 [Cloudflare Pages](https://pages.cloudflare.com/). Pages bundles static assets and serverless Functions in a single free-tier project, which is perfect for the "static site + relayer proxy" split this dapp needs. +- 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 — that's 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/1.4.x/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. - -### Start from the `soroban-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: - -![Github Template Project](/assets/guestbook/github_template.png) - -Or, you can (fork and) clone the template repository locally, and start working that way: +The complete [Ye Olde Guestbook codebase](https://github.com/AshFrancis/ye-olde-guestbook) is freely open and available for reference. You can clone it and start from there: ```shell -git clone https://github.com/ElliotFriend/soroban-template-sveltekit-passkeys ye-olde-guestbook +git clone https://github.com/AshFrancis/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! - -This template will give you a few things to help you hit the ground running: +The repository ships with: -- 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, -- an initialization script to deploy contracts and generate bindings for them, and -- you'll have a ready-to-customize SvelteKit site, written using TypeScript. - -What more could you want!? +- a starter `/contracts` directory containing the guestbook Rust contract, +- a pre-configured `package.json` with Smart Account Kit, the OpenZeppelin Relayer client, and SvelteKit's Cloudflare adapter, +- boilerplate smart-account logic wired up in `src/lib/passkeyClient.ts`, +- an `initialize.js` script that deploys the contract and regenerates bindings, and +- a ready-to-customize SvelteKit app written in TypeScript. ### 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 repo comes with a `.env.example` you'll need to customize. Copy it first: ```shell 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. - -Some variables you will want to change include: +The only values you need to supply yourself are your funder secret key and an OpenZeppelin Channels API key. Everything else has sensible Testnet defaults: ```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 +# The funder account that airdrops 25 XLM into a freshly-deployed smart +# wallet. Create + fund this via Friendbot: https://friendbot.stellar.org +PRIVATE_FUNDER_SECRET_KEY=S...ECRETKEY + +# OpenZeppelin Channels relayer (Testnet). Generate an API key at +# https://channels.openzeppelin.com/testnet/gen +PRIVATE_RELAYER_BASE_URL="https://channels.openzeppelin.com/testnet" +PRIVATE_RELAYER_API_KEY= ``` -### Install Dependencies +The Smart Account WASM hash and WebAuthn verifier address are already filled in with the shared Testnet deployment values — no action needed 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 +105,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..a744fdb006 100644 --- a/docs/build/apps/guestbook/passkeys-prerequisites.mdx +++ b/docs/build/apps/guestbook/passkeys-prerequisites.mdx @@ -3,93 +3,68 @@ 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, deploys an audited [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account) per user, and stores credential metadata in IndexedDB so sessions survive page reloads. -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. +Before we wire up the SDK, we have two prerequisites to set up: an **OpenZeppelin Relayer API key** (so users don't pay their own fees), and a **funder G-account** (so we can drop some Testnet XLM into freshly-deployed smart wallets). -## 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. +Previous iterations of this tutorial used Launchtube as a paymaster service. Launchtube has since been retired; its successor is the [OpenZeppelin Relayer](https://docs.openzeppelin.com/relayer/1.4.x/guides/stellar-channels-guide) with the Stellar Channels plugin. The relayer submits transactions for us through a pool of managed channel accounts, covering fees and sequence-number coordination. -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 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: - -```shell -PRIVATE_LAUNCHTUBE_JWT= -``` + ```shell + PRIVATE_RELAYER_BASE_URL="https://channels.openzeppelin.com/testnet" + PRIVATE_RELAYER_API_KEY= + ``` :::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 bundled into the client 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. +:::warning Never expose the relayer key client-side -1. Clone the `passkey-kit` repository from GitHub and enter the `zephyr` directory within it: +Any browser call to OpenZeppelin's Channels relayer has to carry the API key as a Bearer header, which would expose it to anyone inspecting network traffic. This tutorial keeps the key server-side by routing all submissions through a same-origin `/api/relay` proxy (implemented as a Cloudflare Pages Function). We'll build that proxy in the [next section](./setup-passkeys.mdx). - ```shell - git clone https://github.com/kalepail/passkey-kit - cd passkey-kit/zephyr - ``` +::: -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. +## Funder account - ![Mercury Data JWT Token](/assets/guestbook/mercury_token.png) +Friendbot only funds classic `G...` accounts, not contract `C...` accounts. So, when a new user signs up and their smart wallet is deployed, we need a way to drop a little XLM into it so they can transact. We handle this with a dedicated **funder** — a classic Stellar account holding some Testnet XLM that our server-side `/api/fund/[address]` endpoint uses to send 25 XLM to each freshly-created wallet. - Copy/paste this token into the `.env` file: +1. Create a new Testnet keypair using the Stellar Lab or the Stellar CLI: ```shell - PRIVATE_MERCURY_JWT= + stellar keys generate funder --network testnet --fund ``` -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: +2. Grab the secret key: ```shell - curl -X POST https://api.mercurydata.app/v2/key \ - -H "authorization: Bearer + stellar keys secret funder ``` - 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. - - :::info - - 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. - - ::: - -4. Compile and deploy the event indexer Zephyr program to the Testnet network. +3. Add it to `.env`: ```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 + PRIVATE_FUNDER_SECRET_KEY=S...ECRETKEY ``` -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! +Smart Account Kit stores credential metadata in the browser's IndexedDB, so there's no passkey → contract reverse-lookup indexer to set up. ## 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 these prerequisites 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. **Relayer key returns 401/403.** Regenerate the key at [https://channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen). The Testnet key is free to re-issue. +2. **Funder account has no XLM.** Re-fund it via [Friendbot](https://friendbot.stellar.org) or `stellar keys fund funder --network testnet`. Each funding adds 10,000 XLM, which is plenty for this tutorial. +3. **`PRIVATE_*` env vars come back undefined.** SvelteKit reads them from `.env` at build time. If you edit `.env` while `pnpm dev` is running, restart the dev server. -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). diff --git a/docs/build/apps/guestbook/setup-passkeys.mdx b/docs/build/apps/guestbook/setup-passkeys.mdx index 1e56bf4a0c..e5e94f5326 100644 --- a/docs/build/apps/guestbook/setup-passkeys.mdx +++ b/docs/build/apps/guestbook/setup-passkeys.mdx @@ -3,251 +3,420 @@ 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 and funder account in place, we wire up Smart Account Kit (browser-side) and a same-origin proxy endpoint that forwards transactions on to the OpenZeppelin Relayer (server-side, so the API key stays off the wire). + +Two library files: + +- `src/lib/passkeyClient.ts` — the browser-facing smart account and a `send()` wrapper. +- `src/lib/server/relayer.ts` — the server-side submitter. Forwards `{func, auth}` payloads to OpenZeppelin Channels with Bearer auth, and pushes fully-signed XDR envelopes straight to Stellar RPC. + +Two SvelteKit API routes expose that server logic to the client: + +- `src/routes/api/relay/+server.ts` — the `/api/relay` proxy. Smart Account Kit POSTs here directly (we set `relayerUrl: '/api/relay'` in the kit config). +- `src/routes/api/fund/[address]/+server.ts` — airdrops 25 Testnet XLM to newly-deployed wallets via our funder G-account. ## Passkey client -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. +The `account` export is our user-facing smart account. Users sign up with `account.createWallet()`, log in with `account.connectWallet()`, and the `send()` helper signs + submits any Soroban call through the `/api/relay` proxy. -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. +This file lives at `src/lib/passkeyClient.ts`. The SvelteKit [`$lib` import alias](https://svelte.dev/docs/kit/$lib) makes it reachable from anywhere in the frontend. + +```ts title="src/lib/passkeyClient.ts" +import { Server } from "@stellar/stellar-sdk/rpc"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import type { SmartAccountKit } from "smart-account-kit"; -```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, } from "$env/static/public"; -export const account = new PasskeyKit({ - rpcUrl: PUBLIC_STELLAR_RPC_URL, - networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE, - walletWasmHash: PUBLIC_WALLET_WASM_HASH, -}); +/** + * A Stellar RPC server instance. Safe to import from server routes — this is + * the stock stellar-sdk RPC client, with no browser dependencies. + */ +export const rpc = new Server(PUBLIC_STELLAR_RPC_URL); + +// SmartAccountKit depends on WebAuthn + IndexedDB, and its ESM build trips +// Node's strict directory-import resolver during SvelteKit's SSR prerender. +// We dynamic-import it on first touch so the server bundle never pulls it in. +let kitPromise: Promise | null = null; + +function loadKit(): Promise { + if (kitPromise) return kitPromise; + kitPromise = import("smart-account-kit").then( + ({ SmartAccountKit, IndexedDBStorage }) => { + return new SmartAccountKit({ + rpcUrl: PUBLIC_STELLAR_RPC_URL, + networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE, + accountWasmHash: PUBLIC_ACCOUNT_WASM_HASH, + webauthnVerifierAddress: PUBLIC_WEBAUTHN_VERIFIER_ADDRESS, + storage: new IndexedDBStorage(), + rpName: "Ye Olde Guestbook", + // Route submissions through our same-origin proxy so the Channels + // API key lives on the server, not in the client bundle. + relayerUrl: "/api/relay", + }); + }, + ); + return kitPromise; +} ``` -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: +The key configuration values: + +- **`accountWasmHash`** — the Wasm hash of the [OpenZeppelin Smart Account](https://docs.openzeppelin.com/stellar-contracts/accounts/smart-account) contract. Every new user wallet is a fresh instance of this contract. The shared Testnet hash is pre-filled in `.env.example`. +- **`webauthnVerifierAddress`** — the contract address of the shared WebAuthn signature verifier, which validates `secp256r1` signatures on-chain. +- **`relayerUrl: '/api/relay'`** — same-origin proxy so the OZ Channels API key never reaches the browser. +- **`storage: new IndexedDBStorage()`** — persists the user's credential ID + contract address so sessions survive reloads. + +Next, the `account` export — a `Proxy` that lazy-loads the kit on first method call. This means server-side imports of this module never pay the cost of loading the browser-only SDK. + +```ts title="src/lib/passkeyClient.ts" +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AccountMethod = (...args: any[]) => Promise; + +export const account: Record = new Proxy( + {} as Record, + { + get(_target, prop: string | symbol) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return async (...args: any[]) => { + const kit = await loadKit(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fn = (kit as any)[prop]; + if (typeof fn !== "function") { + throw new Error(`smart account has no method ${String(prop)}`); + } + return fn.apply(kit, args); + }; + }, + }, +); +``` -- 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. +That's enough for authentication. Two small helpers round out the module: - ```js title="src/lib/passkeyClient.ts" - import { Server } from "@stellar/stellar-sdk/rpc"; +```ts title="src/lib/passkeyClient.ts" +/** + * Signs the supplied AssembledTransaction with the connected smart account + * (triggering a passkey prompt) and submits it through the /api/relay proxy. + */ +export async function send(tx: AssembledTransaction) { + const kit = await loadKit(); + return kit.signAndSubmit(tx); +} - /** - * A configured Stellar RPC server instance used to interact with the network - */ - export const rpc = new Server(PUBLIC_STELLAR_RPC_URL); - ``` +/** + * Hits the /api/fund/[address] endpoint to airdrop 25 Testnet XLM into a + * freshly-deployed smart wallet. + */ +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(); + }); +} +``` -- 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`. +:::note No SAC wrapper - ```js title="src/lib/passkeyClient.ts" - import { SACClient } from "passkey-kit"; - import { PUBLIC_NATIVE_CONTRACT_ADDRESS } from "$env/static/public"; +Earlier iterations (and the passkey-kit version of this tutorial) exposed a `native` SAC client so components could write `native.transfer({from, to, amount})`. Smart Account Kit doesn't ship an equivalent. Trying to load the SAC spec dynamically via `contract.Client.from()` turned out to be fragile under production bundling (the spec's XDR encoder trips in the browser bundle), so this tutorial builds SAC calls directly with stellar-sdk primitives when it needs them — see the [frontend walkthrough](./frontend.mdx) and the fund endpoint below. - /** - * 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, - }); +::: - /** - * A SAC client for the native XLM asset. - */ - export const native = sac.getSACClient(PUBLIC_NATIVE_CONTRACT_ADDRESS); - ``` +## Server-side relayer -## Passkey server +The server-side submitter lives at `src/lib/server/relayer.ts`. SvelteKit [enforces that the `$lib/server` directory never leaks into client code](https://svelte.dev/docs/kit/server-only-modules#Your-modules), so the API key can't escape the server bundle. -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?! +Because of how OpenZeppelin Channels handles fees, we split by payload shape: -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. +- **`{ func, auth }`** (Soroban host function + auth entries) → forwarded to OZ Channels, which wraps it in a channel-account transaction and pays fees. This is the sponsored path that user actions take (`kit.signAndSubmit`). +- **`{ xdr }`** (fully-signed envelope) → sent straight to Stellar RPC. Smart Account Kit's wallet-deploy flow takes this path: it signs with its shared deployer keypair (well-known, pre-funded on Testnet). OZ Channels rejects signed envelopes where `tx.fee != tx.sorobanData.resourceFee` — stellar-sdk's default fee includes a safety margin that breaks that strict equality — so we bypass the relayer for this specific case. -```js title="src/lib/server/passkeyServer.ts" -import { PasskeyServer } from "passkey-kit"; +```ts title="src/lib/server/relayer.ts" +import { Server } from "@stellar/stellar-sdk/rpc"; +import { TransactionBuilder } from "@stellar/stellar-sdk"; import { - PUBLIC_LAUNCHTUBE_URL, - PUBLIC_MERCURY_URL, + PRIVATE_RELAYER_BASE_URL, + PRIVATE_RELAYER_API_KEY, +} from "$env/static/private"; +import { + PUBLIC_STELLAR_NETWORK_PASSPHRASE, PUBLIC_STELLAR_RPC_URL, } from "$env/static/public"; -import { - PRIVATE_LAUNCHTUBE_JWT, - PRIVATE_MERCURY_JWT, -} from "$env/static/private"; -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 -}); -``` +export interface RelaySuccess { + hash: string; +} + +export async function submitToRelayer( + payload: { xdr: string } | { func: string; auth: string[] }, +): Promise { + if ("xdr" in payload) { + return submitSignedXdrToRpc(payload.xdr); + } + return submitSorobanToChannels(payload); +} + +async function submitSorobanToChannels(payload: { + func: string; + auth: string[]; +}): Promise { + if (!PRIVATE_RELAYER_BASE_URL || !PRIVATE_RELAYER_API_KEY) { + throw new Error("OpenZeppelin Relayer is not configured."); + } + const baseUrl = PRIVATE_RELAYER_BASE_URL.replace(/\/+$/, ""); + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${PRIVATE_RELAYER_API_KEY}`, + }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(extractError(data, response.status)); + const hash = extractHash(data); + if (!hash) throw new Error("relayer returned no transaction hash"); + return { hash }; +} -And you're done with the `PasskeyServer`! Well done! +async function submitSignedXdrToRpc(xdr: string): Promise { + const rpc = new Server(PUBLIC_STELLAR_RPC_URL); + const tx = TransactionBuilder.fromXDR(xdr, PUBLIC_STELLAR_NETWORK_PASSPHRASE); + const sendResult = await rpc.sendTransaction(tx); + if (sendResult.status === "ERROR") { + const detail = sendResult.errorResult + ? sendResult.errorResult.result().switch().name + : "unknown"; + throw new Error(`RPC rejected transaction: ${detail}`); + } + if (sendResult.status !== "PENDING") { + throw new Error(`unexpected RPC status: ${sendResult.status}`); + } + // Poll briefly for confirmation so we surface failures early. + for (let attempt = 0; attempt < 15; attempt++) { + const status = await rpc.getTransaction(sendResult.hash); + if (status.status === "SUCCESS") return { hash: sendResult.hash }; + if (status.status === "FAILED") + throw new Error("transaction failed on-chain"); + await new Promise((r) => setTimeout(r, 1000)); + } + return { hash: sendResult.hash }; +} -This `server` instance will be used for sending transactions (via Launchtube) and reverse-looking-up contract addresses from a known passkey ID (via Mercury). +function extractError(data: unknown, status: number): string { + if (data && typeof data === "object") { + const d = data as { + error?: unknown; + data?: { details?: { message?: unknown } }; + }; + const nested = + d.data && typeof d.data === "object" ? d.data.details?.message : null; + if (typeof nested === "string") return nested; + if (typeof d.error === "string") return d.error; + } + return `relayer returned HTTP ${status}`; +} -### API routes +function extractHash(data: unknown): string | null { + if (!data || typeof data !== "object") return null; + const outer = data as { data?: { hash?: unknown }; hash?: unknown }; + const inner = + outer.data && typeof outer.data === "object" ? outer.data.hash : undefined; + const hash = inner ?? outer.hash; + return typeof hash === "string" && hash.length > 0 ? hash : null; +} +``` -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. +Channels accepts a simple `fetch` with a Bearer header; [`@openzeppelin/relayer-plugin-channels`](https://www.npmjs.com/package/@openzeppelin/relayer-plugin-channels) is a more batteries-included option if you're on Node. -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. +## API routes -#### `/api/send` +Now we expose the relayer and funder to the client via two SvelteKit routes. Anything named `*server.{ts,svelte}` runs [only on the server](https://svelte.dev/docs/kit/routing#server) — exactly where secrets belong. -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. +### `/api/relay` -:::warning +Smart Account Kit's `signAndSubmit()` POSTs here automatically because we set `relayerUrl: '/api/relay'` in the kit config. -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! +:::warning Production checklist -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. +For production, add rate limiting and origin checking to this endpoint so a bad actor can't burn your relayer credits forwarding arbitrary transactions. Out of scope for this tutorial, but don't skip it before going to Mainnet. ::: -```js title="src/routes/api/send/+server.ts" -import { server } from "$lib/server/passkeyServer"; -import { json } from "@sveltejs/kit"; +```ts title="src/routes/api/relay/+server.ts" import type { RequestHandler } from "./$types"; +import { error, json } from "@sveltejs/kit"; -export const POST: RequestHandler = async ({ request }) => { - const { xdr } = await request.json(); - const res = await server.send(xdr); - return json(res); -}; -``` - -#### `/api/contract/[signer]` +import { submitToRelayer } from "$lib/server/relayer"; -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. - -```js title="src/routes/api/contract/[signer]/+server.ts" +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") { + throw error(400, "request body must be a JSON object"); + } -import { server } from '$lib/server/passkeyServer'; -import type { RequestHandler } from './$types'; + const xdr = typeof body.xdr === "string" ? body.xdr.trim() : ""; + const func = typeof body.func === "string" ? body.func.trim() : ""; -export const GET: RequestHandler = async ({ params }) => { - const contractId = await server.getContractId(params.signer!); - - return new Response(String(contractId)); + try { + if (xdr.length > 0) { + return json(await submitToRelayer({ xdr })); + } + if (func.length > 0) { + const auth = Array.isArray(body.auth) + ? body.auth.filter((a: unknown): a is string => typeof a === "string") + : []; + return json(await submitToRelayer({ func, auth })); + } + throw error(400, "provide either `xdr` or `func` (+ optional `auth`)"); + } catch (err) { + // Re-throw SvelteKit HttpErrors (from the 400s above) unchanged. + if (err && typeof err === "object" && "status" in err && "body" in err) + throw err; + console.error("[relay] submission failed:", err); + throw error( + 502, + err instanceof Error ? err.message : "relayer submission failed", + ); + } }; ``` -#### `/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" +### `/api/fund/[address]` -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'; +Friendbot doesn't fund `C...` addresses, so we sponsor new smart wallets from a classic G-account funder. We build the SAC `transfer(from, to, amount)` call directly and assemble the Soroban transaction by hand, sidestepping `contract.Client.from(...)` and `rpc.prepareTransaction`/`assembleTransaction` — both paths ship subtle failures under the Cloudflare Workers bundler. -export const GET: RequestHandler = async ({ params, fetch }) => { - const fundKeypair = Keypair.fromSecret(PRIVATE_FUNDER_SECRET_KEY); - const fundSigner = basicNodeSigner(fundKeypair, PUBLIC_STELLAR_NETWORK_PASSPHRASE); +```ts title="src/routes/api/fund/[address]/+server.ts" +import type { RequestHandler } from "./$types"; +import { error, json } from "@sveltejs/kit"; - try { - const { built, ...transfer } = await native.transfer({ - from: fundKeypair.publicKey(), - to: params.address, - amount: BigInt(25 * 10_000_000), - }); +import { + Address, + Asset, + BASE_FEE, + Contract, + Keypair, + Operation, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { Api, Server } from "@stellar/stellar-sdk/rpc"; + +import { PRIVATE_FUNDER_SECRET_KEY } from "$env/static/private"; +import { + PUBLIC_STELLAR_NETWORK_PASSPHRASE, + PUBLIC_STELLAR_RPC_URL, +} from "$env/static/public"; - await transfer.signAuthEntries({ - publicKey: fundKeypair.publicKey(), - signAuthEntry: (auth) => fundSigner.signAuthEntry(auth), - }); +export const GET: RequestHandler = async ({ params }) => { + const fundKeypair = Keypair.fromSecret(PRIVATE_FUNDER_SECRET_KEY); + const rpc = new Server(PUBLIC_STELLAR_RPC_URL); + + try { + const nativeContractId = Asset.native().contractId( + PUBLIC_STELLAR_NETWORK_PASSPHRASE, + ); + const sac = new Contract(nativeContractId); + + const amount = xdr.ScVal.scvI128( + new xdr.Int128Parts({ + hi: xdr.Int64.fromString("0"), + lo: xdr.Uint64.fromString((25n * 10_000_000n).toString()), + }), + ); + const transferArgs = [ + new Address(fundKeypair.publicKey()).toScVal(), + new Address(params.address).toScVal(), + amount, + ]; + + // Simulate to extract the Soroban transaction data + minResourceFee. + const simAccount = await rpc.getAccount(fundKeypair.publicKey()); + const simTx = new TransactionBuilder(simAccount, { + fee: BASE_FEE, + networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation(sac.call("transfer", ...transferArgs)) + .setTimeout(60) + .build(); + + const sim = await rpc.simulateTransaction(simTx); + if (Api.isSimulationError(sim)) { + throw new Error(`simulation failed: ${sim.error}`); + } + if (!Api.isSimulationSuccess(sim)) { + throw new Error("simulation did not succeed"); + } - await fetch('/api/send', { - method: 'POST', - body: JSON.stringify({ - xdr: built!.toXDR(), + const simAuth = sim.result?.auth ?? []; + const sorobanData = sim.transactionData.build(); + const minResourceFee = Number(sim.minResourceFee); + + // Rebuild the tx from scratch with the assembled fee + Soroban data. + // Avoids `rpc.prepareTransaction`/`assembleTransaction`, both of which + // go through `TransactionBuilder.cloneFrom` + `instanceof Transaction`. + const account = await rpc.getAccount(fundKeypair.publicKey()); + const assembledTx = new TransactionBuilder(account, { + fee: (Number(BASE_FEE) + minResourceFee).toString(), + networkPassphrase: PUBLIC_STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + Operation.invokeHostFunction({ + func: xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: new Address(nativeContractId).toScAddress(), + functionName: "transfer", + args: transferArgs, }), - }); + ), + auth: simAuth, + }), + ) + .setSorobanData(sorobanData) + .setTimeout(60) + .build(); + + assembledTx.sign(fundKeypair); + + const sendResult = await rpc.sendTransaction(assembledTx); + if (sendResult.status === "ERROR") { + const reason = sendResult.errorResult + ? sendResult.errorResult.result().switch().name + : "unknown"; + throw new Error(`RPC rejected fund tx: ${reason}`); + } + if (sendResult.status !== "PENDING") { + throw new Error(`unexpected RPC status: ${sendResult.status}`); + } + const hash = sendResult.hash; + for (let attempt = 0; attempt < 20; attempt++) { + const status = await rpc.getTransaction(hash); + if (status.status === "SUCCESS") { return json({ - status: 200, - message: 'Smart wallet successfully funded', - }); - } catch (err) { - console.error(err); - error(500, { - message: 'Error when funding smart wallet', + status: 200, + message: "Smart wallet successfully funded", + hash, }); + } + if (status.status === "FAILED") { + throw new Error("fund transaction failed on-chain"); + } + await new Promise((r) => setTimeout(r, 1000)); } + return json({ status: 202, message: "Fund tx pending", hash }); + } catch (err) { + console.error("[fund]", err); + const detail = err instanceof Error ? err.message : String(err); + throw error(500, `Error when funding smart wallet: ${detail}`); + } }; ``` -### Passkey client helpers - -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. - -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: - -```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(); - }); -} - -/** - * 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(); - }); -} - -/** - * 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! +That's the whole server side: one lib file and two API routes. Everything else — signup, login, donate, write/edit messages — lives in the frontend and talks to these routes through the `send()` / `fundContract()` helpers from `passkeyClient.ts`. On to the UI. diff --git a/docs/build/apps/guestbook/smart-contract.mdx b/docs/build/apps/guestbook/smart-contract.mdx index ca68bd8d3c..c6fbba52ab 100644 --- a/docs/build/apps/guestbook/smart-contract.mdx +++ b/docs/build/apps/guestbook/smart-contract.mdx @@ -17,7 +17,7 @@ All the following "business logic" will be handled by our smart contract: :::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 here: [https://github.com/AshFrancis/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/lib.rs](https://github.com/AshFrancis/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/lib.rs) ::: @@ -327,7 +327,7 @@ fn save_message(env: &Env, message: Message) -> u32 { ### Contract types -In addition to the functions above, we have some custom types written for our smart contract. These can be seen in the [`types.rs file`](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/types.rs) in the source code repository. +In addition to the functions above, we have some custom types written for our smart contract. These can be seen in the [`types.rs file`](https://github.com/AshFrancis/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/types.rs) in the source code repository. #### `Message` @@ -376,6 +376,6 @@ pub enum Error { #### Tests -We've written some tests that work through many (foreseen) usage patterns for this smart contract. It's too lengthy to dive into here, but it's worth checking out the [source code](https://github.com/ElliotFriend/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/test.rs) to understand the logic of how the various contract functions are meant to work together. +We've written some tests that work through many (foreseen) usage patterns for this smart contract. It's too lengthy to dive into here, but it's worth checking out the [source code](https://github.com/AshFrancis/ye-olde-guestbook/blob/main/contracts/ye_olde_guestbook/src/test.rs) to understand the logic of how the various contract functions are meant to work together. Up next, we'll look at how we go from this deployed contract to an NPM package that can be imported and used in a frontend project easily, and with full type-safety. diff --git a/docs/build/guides/contract-accounts/advanced-patterns.mdx b/docs/build/guides/contract-accounts/advanced-patterns.mdx index e8fc4fd586..b59f518e09 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..4cddb5beba 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/1.4.x/guides/stellar-channels-guide](https://docs.openzeppelin.com/relayer/1.4.x/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