diff --git a/docs/build/apps/dapp-frontend.mdx b/docs/build/apps/dapp-frontend.mdx index bc11c378a5..2c020a3d76 100644 --- a/docs/build/apps/dapp-frontend.mdx +++ b/docs/build/apps/dapp-frontend.mdx @@ -1,561 +1,510 @@ --- sidebar_position: 70 -sidebar_label: Build a Dapp Frontend +sidebar_label: Develop a Contract with Frontend Templates title: "Build a dapp Frontend: Connect Wallets, Handle Transactions & More" description: "Learn how to build a dapp frontend that connects to smart contracts. Explore best practices for integrating wallets, handling transactions, and interacting with the Stellar network." --- -# Build a Dapp Frontend +# Develop a Contract with Frontend Templates -This is a continuation of the [Getting Started tutorial](../smart-contracts/getting-started/README.mdx), where you should have deployed two smart contracts to the public network. In this section, we'll create a web app that interacts with the contracts via RPC calls. +This guide picks up where [Getting Started tutorial](../smart-contracts/getting-started) left off. From there, we'll build our own simple frontend template. -Let's get started. +Building our own template will be a great way to learn how they work. They're not that complicated! -## Initialize a frontend toolchain +## Make your own template -You can build a Soroban app with any frontend toolchain or integrate it into any existing full-stack app. For this tutorial, we're going to use [Astro](https://astro.build/). Astro works with React, Vue, Svelte, any other UI library, or no UI library at all. In this tutorial, we're not using a UI library. The Soroban-specific parts of this tutorial will be similar no matter what frontend toolchain you use. +Let’s make our own template! In this example template, we use SolidJS as the JavaScript framework, but other frameworks can be used with minor modifications. The template is using the `hello-world` example smart contract. As a part of the template initialization, bindings for the `hello-world` smart contract are created. -If you're new to frontend, don't worry. We won't go too deep. But it will be useful for you to see and experience the frontend development process used by Soroban apps. We'll cover the relevant bits of JavaScript and Astro, but teaching all of frontend development and Astro is beyond the scope of this tutorial. +This example template is very simple, most of the work goes into creating the `initialize.js` file, which is used to take care of creating a user account, building and deploying the smart contract, and creating the smart contract TypeScript bindings. -Let's get started. +### 1. Initialize a SolidJS project -You're going to need [Node.js](https://nodejs.org/en/download/package-manager/) v18.14.1 or greater. If you haven't yet, install it now. +```bash +# See https://github.com/solidjs/templates for SolidJS template options +npx degit solidjs/templates/vanilla/bare soroban-template-solid +cd soroban-template-solid +npm install +npm run dev +``` -We want to create an Astro project with the contracts from the previous lesson. To do this, we can clone a template. You can find Soroban templates on GitHub by [searching for repositories that start with "soroban-template-"](https://github.com/search?q=%22soroban-template-%22&type=repositories). For this tutorial, we'll use [stellar/soroban-template-astro](https://github.com/stellar/soroban-template-astro). We'll also use a tool called [degit](https://github.com/Rich-Harris/degit) to clone the template without its git history. This will allow us to set it up as our own git project. +The basic SolidJS is now running on localhost port 3000. -Since you have `node` and its package manager `npm` installed, you also have `npx`. +#### Dependencies -We're going to create a new project directory with this template to make things easier in this tutorial, so make sure you're no longer in your `soroban-hello-world` directory and then run: +Most of the needed dependencies are already included by the SolidJS template, we just need to add three more: -```sh -npx degit stellar/soroban-template-astro first-soroban-app -cd first-soroban-app -git init -git add . -git commit -m "first commit: initialize from stellar/soroban-template-astro" -``` +```bash -This project has the following directory structure, which we'll go over in more detail below. +npm install dotenv glob util -```bash -├── contracts -│   ├── hello_world -│   └── increment -├── CONTRIBUTING.md -├── Cargo.toml -├── Cargo.lock -├── initialize.js -├── package-lock.json -├── package.json -├── packages -├── public -├── src -│   ├── components -│   │   └── Card.astro -│   ├── env.d.ts -│   ├── layouts -│   │   └── Layout.astro -│   └── pages -│   └── index.astro -└── tsconfig.json ``` -The `contracts` are the same ones you walked through in the previous steps of the tutorial. Since we already deployed these contracts with aliases, we can reuse the generated contract ID files by copying them from the `soroban-hello-world/.stellar` directory into this project: +The `dotenv` package is needed for reading the environment variables, `glob` is used to find files in the project based on a pattern, and `util` contains a function that can be used to execute system commands asynchronously. + +#### Smart contract + +If you already have the `hello-world` smart contract, it can be used for the following steps. If not, run the `stellar contract init heelo-world` CLI command in you project root folder. Since we are going to interact with the smart contract from the `initialize.js` script, add the relative path to the smart contract in the `.env` file in the next step so the `initialize.js` script can find the contract files. + +The root directory should look like this: + +```text +├── hello-world +│ └── contracts +│ └── hello-world +│ ├── src +│ │ └── lib.rs +│ ├── Cargo.toml +│ └── Makefile +└── soroban-template-solid + ├── node_modules + ├── packages + ├── src + │ ├── App.tsx + │ └── index.tsx + ├── .env + ├── index.html + ├── tsconfig.json + ├── vite.config.ts + ├── initialize.js + ├── package.json + └── Cargo.toml -```sh -cp -R ../soroban-hello-world/.stellar/ .stellar ``` -## Generate an NPM package for the Hello World contract +### 2. Environment variables -Before we open the new frontend files, let's generate an NPM package for the Hello World contract. This is our suggested way to interact with contracts from frontends. These generated libraries work with any JavaScript project (not a specific UI like React), and make it easy to work with some of the trickiest bits of Soroban, like encoding [XDR](../../learn/fundamentals/contract-development/types/fully-typed-contracts.mdx). +The SolidJS code itself doesn’t need environment variables for this simple example, but since we are going to add smart contract bindings, it makes sense to store information about the network and the user in an .env file instead of hard coding those values. -This is going to use the CLI command `stellar contract bindings typescript`: +These are the variables needed: ```bash -stellar contract bindings typescript \ - --network testnet \ - --contract-id hello_world \ - --output-dir packages/hello_world +STELLAR_NETWORK="testnet" +STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +STELLAR_RPC_URL="https://soroban-testnet.stellar.org" +STELLAR_ACCOUNT="my-user-name" +STELLAR_CONTRACT_PATH="../hello-world" ``` -:::tip +The variables used here are for deploying the contract to testnet and creating the contract bindings for testnet. The user name can be any name, but let’s say you use alice, and have previously created the user `alice` with the Stellar CLI, creating a new account named `alice` will fail. -Notice that we were able to use the contract alias, `hello_world`, in place of the contract id! +### 3. initialize.js -::: +The goal is to have a script that will handle everything smart contract-related, from creating a user account to deploying the smart contract and providing a TypeScript binding for easy smart contract calls from frontend code. The file `initialize.js` contains that script, and the functionality of it will be broken down in the following sections. -This project is set up as an NPM Workspace, and so the `hello_world` client library was generated in the `packages` directory at `packages/hello_world`. +#### Definitions -We attempt to keep the code in these generated libraries readable, so go ahead and look around. Open up the new `packages/hello_world` directory in your editor. If you've built or contributed to Node projects, it will all look familiar. You'll see a `package.json` file, a `src` directory, a `tsconfig.json`, and even a README. +Before diving into the functions in the `initialize.js` script, a few constants and variables are defined. The most noteworthy here is `execAsync()`, which will let us execute CLI commands and wait for the command responses. -## Generate an NPM package for the Increment contract +```javascript +// Get directory names +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); -Though we can run `stellar contract bindings typescript` for each of our contracts individually, the [soroban-template-astro](https://github.com/stellar/soroban-astro-template) project that we used as our template includes a very handy `initialize.js` script that will handle this for all of the contracts in our `contracts` directory. +// Define array to hold deployed smart contract info +var smartContracts = Array(); -In addition to generating the NPM packages, `initialize.js` will also: - -- Generate and fund our Stellar account -- Build all of the contracts in the `contracts` dir -- Deploy our contracts -- Create handy contract clients for each contract +// Run exec commands asynchronously +const execAsync = promisify(exec); +``` -We have already taken care of the first three bullet points in earlier steps of this tutorial, so those tasks will be noops when we run `initialize.js`. +#### User -### Configure initialize.js +Now that we have the environment variables, dependencies and definitions taken care of, we can get into the scripts that handle the smart contract deployment and integration. First step towards the integration is to create a user: -We need to make sure that `initialize.js` has all of the environment variables it needs before we do anything else. Copy the `.env.example` file over to `.env`. The environment variables set in `.env` are used by the `initialize.js` script. +```javascript +// ###################### Create User ######################## -```bash -cp .env.example .env +function createUser() { + execSync( + `stellar keys generate --fund ${process.env.STELLAR_ACCOUNT} | true`, + ); +} ``` -Let's take a look at the contents of the `.env` file: +The user is created by calling the Stellar CLI command `stellar keys generate`, and funding it with Friendbot. The user’s name is fetched from the environment variables. -``` -# Prefix with "PUBLIC_" to make available in Astro frontend files -PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017" -PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc" +You can check the new user’s public key by running this CLI command: -STELLAR_ACCOUNT="me" -STELLAR_NETWORK="standalone" -``` - -This `.env` file defaults to connecting to a locally running network, but we want to configure our project to communicate with Testnet, since that is where we deployed our contracts. To do that, let's update the `.env` file to look like this: +```bash -```diff -# Prefix with "PUBLIC_" to make available in Astro frontend files --PUBLIC_STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017" -+PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" --PUBLIC_STELLAR_RPC_URL="http://localhost:8000/soroban/rpc" -+PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443" +stellar keys public-key --STELLAR_ACCOUNT="me" -+STELLAR_ACCOUNT="alice" --STELLAR_NETWORK="standalone" -+STELLAR_NETWORK="testnet" ``` -:::info - -This `.env` file is used in the `initialize.js` script. When using the CLI, we can still use the network configuration we set up in the [Setup](../smart-contracts/getting-started/setup.mdx) step, or by passing the `--rpc-url` and `--network-passphrase` flags. +With the public key, you can look up the account on [Stellar Expert](https://stellar.expert/explorer/testnet). -::: +#### Build contracts -### Run `initialize.js` - -First let's install the Javascript dependencies: - -```bash -npm install -``` +We want the script to build the contract, or contracts in case there are more than one, and it’s a 2-step process. First, we clean up the target folder in case there’s a previous build, and then we call the CLI command to build the contract(s). -And then let's run `initialize.js`: +```javascript +// Remove all previous build files +function removeFiles(pattern) { + glob(pattern).forEach((entry) => rmSync(entry)); +} -```bash -npm run init +function buildAll() { + removeFiles( + `${dirname}/${process.env.STELLAR_CONTRACT_PATH}/target/wasm32v1-none/release/*.wasm`, + ); + removeFiles( + `${dirname}/${process.env.STELLAR_CONTRACT_PATH}/target/wasm32v1-none/release/*.d`, + ); + execSync(`cd ${process.env.STELLAR_CONTRACT_PATH} && stellar contract build`); + console.log("Build complete"); +} ``` -As mentioned above, this script attempts to build and deploy our contracts, which we have already done. The script is smart enough to check if a step has already been taken care of, and is a no-op in that case, so it is safe to run more than once. +The helper function `removeFiles` will delete any `wasm` or `d` files in the target directory. -### Call the contract from the frontend +#### Deploy contracts -Now let's open up `src/pages/index.astro` and take a look at how the frontend code integrates with the NPM package we created for our contracts. +Now that the smart contract has been built, we can deploy it to the network, so we can invoke the its functions from any client, such as our SolidJS template. -Here we can see that we're importing our generated `helloWorld` client from `../contracts/hello_world`. We're then invoking the `hello` method and adding the result to the page. +There are three functions related to contract deployment. One that uses the Stellar CLI to deploy the Wasm to the network (`deploy()`), one that calls the deploy function for each Wasm found (`deployAll()`), in case there is more than one smart contract, and finally a helper function that gets the contract name by parsing the Wasm file name. -```ts title="src/pages/index.astro" ---- -import Layout from "../layouts/Layout.astro"; -import Card from "../components/Card.astro"; -import helloWorld from "../contracts/hello_world"; -const { result } = await helloWorld.hello({ to: "you" }); -const greeting = result.join(" "); ---- +```javascript +// Get smart contract name from filename +function filenameNoExtension(filename) { + return path.basename(filename, path.extname(filename)); +} - ... +async function deploy(wasm) { + // Deploy a single contract and get the contract id + const { stdout, stderr } = await execAsync( + `stellar contract deploy --wasm ${wasm} --ignore-checks --alias ${filenameNoExtension(wasm)} --source ${process.env.STELLAR_ACCOUNT} --network ${process.env.STELLAR_NETWORK} --rpc-url ${process.env.STELLAR_RPC_URL} --network-passphrase "${process.env.STELLAR_NETWORK_PASSPHRASE}"`, + ); + + // Add deployed contract to array with alias, wasm path and contract id + smartContracts.push({ + alias: filenameNoExtension(wasm), + wasm: wasm, + contractid: stdout.trimEnd(), + }); -

{greeting}

-``` + console.log(`Deployed ${filenameNoExtension(wasm)}`); +} -Let's see it in action! Start the dev server: +async function deployAll() { + console.log("Deploying all contracts"); + const wasmFiles = glob( + `${dirname}/${process.env.STELLAR_CONTRACT_PATH}/target/wasm32v1-none/release/*.wasm`, + ); -```bash -npm run dev + for (const wasm of wasmFiles) { + await deploy(wasm); + } +} ``` -And open [localhost:4321](http://localhost:4321) in your browser. You should see the greeting from the contract! +The `deploy()` function will get the contract ID from the CLI call, and add the contract name, wasm file path, and contract ID to the `smartContracts[]` array. -You can try updating the `{ to: 'Soroban' }` argument. When you save the file, the page will automatically update. +In this example, we only use one smart contract, but it’s not uncommon to use multiple smart contracts in a dapp, so the template supports the use of multiple contracts. -:::info +##### Create bindings -When you start up the dev server with `npm run dev`, you will see similar output in your terminal as when you ran `npm run init`. This is because the `dev` script in package.json is set up to run `npm run init` and `astro dev`, so that you can ensure that your deployed contract and your generated NPM pacakage are always in sync. If you want to just start the dev server without the initialize.js script, you can run `npm run astro dev`. +The Stellar CLI has a convenient command to create an NPM package that makes it easy to call smart contract functions from a JavaScript/TypeScript-based frontend. We call the package “bindings” because that’s what it does: it binds the contract and the frontend together. -::: +As with the contract build functions, the binding function is also capable of handling multiple contracts, so there’s a function for creating the binding package for a contract (`bind()`) and a function that calls `bind()` for each contract (`bindAll()`). -### What's happening here? +```javascript +function bind({ alias, wasm, contractid }) { + // Create bindings for a deployed contract + execSync( + `stellar contract bindings typescript --contract-id ${contractid} --output-dir ${dirname}/packages/${alias} --overwrite`, + ); + // Build the package + execSync(`(cd ${dirname}/packages/${alias} && npm i && npm run build)`); +} -If you inspect the page (right-click, inspect) and refresh, you'll see a couple interesting things: +async function bindAll() { + // Bind all deployed contracts + for (const contract of smartContracts) { + await bind(contract); + } +} +``` -- The "Network" tab shows that there are no Fetch/XHR requests made. But RPC calls happen via Fetch/XHR! So how is the frontend calling the contract? -- There's no JavaScript on the page. But we just wrote some JavaScript! How is it working? +The `bindAll()` function iterates the `smartContracts[]` array. The reason for not just using the array of wasms, like in the `deployAll()` function, is that we need the contract ID to invoke the generated bindings functions on the network. -This is part of Astro's philosophy: the frontend should ship with as few assets as possible. Preferably zero JavaScript. When you put JavaScript in the [frontmatter](https://docs.astro.build/en/core-concepts/astro-components/), Astro will run it at build time, and then replace anything in the `{...}` curly brackets with the output. +#### Import bindings -When using the development server with `npm run dev`, it runs the frontmatter code on the server, and injects the resulting values into the page on the client. +The last step is to configure the smart contract bindings client. The `importContract()` function creates a TypeScript file with a script that configures a client based on the smart contract ID, the network passphrase, and the RPC URL. The client makes it easy to make calls in the frontend code to the smart contract functions. -You can try building to see this more dramatically: +The file is stored with the contract name as the file name, and with the `.ts` as the extension, e.g., `hello_world.ts`. -```bash -npm run build -``` +```javascript +function importContract({ alias, wasm, contractid }) { + const outputDir = `${dirname}/src/contracts/`; -Then check the `dist` folder. You'll see that it built an HTML and CSS file, but no JavaScript. And if you look at the HTML file, you'll see a static "Hello Soroban" in the `

`. + mkdirSync(outputDir, { recursive: true }); -During the build, Astro made a single call to your contract, then injected the static result into the page. This is great for contract methods that don't change, but probably won't work for most contract methods. Let's integrate with the `incrementor` contract to see how to handle interactive methods in Astro. --> + const importContent = + `import { Client } from '${alias}';\n` + + `export default new Client({\n` + + ` contractId: "${contractid}",\n` + + ` networkPassphrase: "${process.env.STELLAR_NETWORK_PASSPHRASE}",\n` + + ` rpcUrl: "${process.env.STELLAR_RPC_URL}",\n` + + `${ + process.env.STELLAR_NETWORK === "local" || "standalone" + ? ` allowHttp: true,\n` + : null + }` + + `});\n`; -## Call the incrementor contract from the frontend + const outputPath = `${outputDir}/${alias}.ts`; + writeFileSync(outputPath, importContent); -While `hello` is a simple view-only/read method, `increment` changes on-chain state. This means that someone needs to sign the transaction. So we'll need to add transaction-signing capabilities to the frontend. + console.log(`Created import for ${alias}`); +} -The way signing works in a browser is with a _wallet_. Wallets can be web apps, browser extensions, standalone apps, or even separate hardware devices. +function importAll() { + smartContracts.forEach(importContract); +} +``` -### Install Freighter Extension +#### Main function -Right now, the wallet that best supports Soroban is [Freighter](../guides/freighter/README.mdx). It is available as a Firefox Add-on, as well as extensions for Chrome and Brave. Go ahead and [install it now](https://freighter.app). +At last, we have the main function, which calls the above functions in the right order. Note the asynchronous calls of `deployAll()` and `bindAll()`. The functions following them depend on the completion of the previous functions. -Once it's installed, open it up by clicking the extension icon. If this is your first time using Freighter, you will need to create a new wallet. Go through the prompts to create a password and save your recovery passphrase. +```javascript +// Calling the functions in sequence +async function main() { + createUser(); + buildAll(); + await deployAll(); + await bindAll(); + importAll(); +} -Go to Settings (the gear icon) → Preferences and toggle the switch to Enable Experimental Mode. Then go back to its home screen and select "Test Net" from the top-right dropdown. Finally, if it shows the message that your Stellar address is not funded, go ahead and click the "Fund with Friendbot" button. +main().catch((e) => { + console.error("Initialization failed", e); + process.exit(1); +}); +``` -Now you're all set up to use Freighter as a user, and you can add it to your app. +#### Complete initialize.js file -### Add the StellarWalletsKit and set it up +This is the complete file. Place it in the SolidJS root: -Even though we're using Freighter to test our app, there are more wallets that support signing smart contract transactions. To make their integration easier, we are using the `StellarWalletsKit` library which allows us support all Stellar Wallets with a single library. +```javascript +import "dotenv/config"; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from "fs"; +import { execSync, exec } from "child_process"; +import path from "path"; +import { fileURLToPath } from "url"; +import { sync as glob } from "glob"; +import { promisify } from "util"; -To install this kit we are going to include the next package: +// ###################### Definitions ######################## -```shell -npm install @creit.tech/stellar-wallets-kit -``` +// Get directory names +const __filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(__filename); -With the package installed, we are going to create a new simple file where our instantiated kit and simple state will be located. Create the file `src/stellar-wallets-kit.ts` and paste this: +// Define array to hold deployed smart contracts +var smartContracts = Array(); -```ts title="src/stellar-wallets-kit.ts" -import { - allowAllModules, - FREIGHTER_ID, - StellarWalletsKit, -} from "@creit.tech/stellar-wallets-kit"; +// Run exec commands asynchronously +const execAsync = promisify(exec); -const SELECTED_WALLET_ID = "selectedWalletId"; +// ###################### Create User ######################## -function getSelectedWalletId() { - return localStorage.getItem(SELECTED_WALLET_ID); +function createUser() { + execSync( + `stellar keys generate --fund ${process.env.STELLAR_ACCOUNT} | true`, + ); } -const kit = new StellarWalletsKit({ - modules: allowAllModules(), - network: import.meta.env.PUBLIC_STELLAR_NETWORK_PASSPHRASE, - // StellarWalletsKit forces you to specify a wallet, even if the user didn't - // select one yet, so we default to Freighter. - // We'll work around this later in `getPublicKey`. - selectedWalletId: getSelectedWalletId() ?? FREIGHTER_ID, -}); - -export const signTransaction = kit.signTransaction.bind(kit); +// ###################### Build Contracts ######################## -export async function getPublicKey() { - if (!getSelectedWalletId()) return null; - const { address } = await kit.getAddress(); - return address; +// Remove all previous build files +function removeFiles(pattern) { + glob(pattern).forEach((entry) => rmSync(entry)); } -export async function setWallet(walletId: string) { - localStorage.setItem(SELECTED_WALLET_ID, walletId); - kit.setWallet(walletId); +function buildAll() { + removeFiles(`${dirname}/target/wasm32v1-none/release/*.wasm`); + removeFiles(`${dirname}/target/wasm32v1-none/release/*.d`); + execSync(`stellar contract build`); + console.log("Build complete"); } -export async function disconnect(callback?: () => Promise) { - localStorage.removeItem(SELECTED_WALLET_ID); - kit.disconnect(); - if (callback) await callback(); +// ###################### Deploy Contracts ######################## + +// Get smart contract name from filename +function filenameNoExtension(filename) { + return path.basename(filename, path.extname(filename)); } -export async function connect(callback?: () => Promise) { - await kit.openModal({ - onWalletSelected: async (option) => { - try { - await setWallet(option.id); - if (callback) await callback(); - } catch (e) { - console.error(e); - } - return option.id; - }, +async function deploy(wasm) { + // Deploy a single contract and get the contract id + const { stdout, stderr } = await execAsync( + `stellar contract deploy --wasm ${wasm} --ignore-checks --alias ${filenameNoExtension(wasm)} --source ${process.env.STELLAR_ACCOUNT} --network ${process.env.STELLAR_NETWORK} --rpc-url ${process.env.STELLAR_RPC_URL} --network-passphrase "${process.env.STELLAR_NETWORK_PASSPHRASE}"`, + ); + + // Add deployed contract to array with alias, wasm path and contract id + smartContracts.push({ + alias: filenameNoExtension(wasm), + wasm: wasm, + contractid: stdout.substring(0, stdout.length - 1), }); + + console.log(`Deployed ${filenameNoExtension(wasm)}`); } -``` -In the code above, we instantiate the kit with desired settings and export it. We also wrap some kit functions and add custom functionality, such as augmenting the kit by allowing it to remember which wallet options was selected between page refreshes (that's the `localStorage` bit). The kit requires a `selectedWalletId` even before the user selects one, so we also work around this limitation, as the code comment explains. You can learn more about how the kit works in [the StellarWalletsKit documentation](https://stellarwalletskit.dev/) - -Now we're going to add a "Connect" button to the page which will open the kit's built-in modal, and prompt the user to use their preferred wallet. Once the user picks their preferred wallet and grants permission to accept requests from the website, we will fetch the public key and the "Connect" button will be replaced with a message saying, "Signed in as [their public key]". - -Now let's add a new component to the `src/components` directory called `ConnectWallet.astro` with the following content: - -```html title="src/components/ConnectWallet.astro" -
-   -
- - -
- - - - -``` - -Some of this may look surprising. `