Develop a Contract with Frontend Templates
This guide picks up where Build a Dapp Frontend left off. From there, we'll:
- Search GitHub for other Soroban templates
- Build our own simple template
Building our own template will be a great way to learn how they work. They're not that complicated!
Search GitHub for other Soroban templates
The official template maintained by Stellar Development Foundation (SDF), as used in Build a Dapp Frontend, lives on GitHub at stellar/soroban-template-astro. It uses the Astro web framework. While Astro works with React, Vue, Svelte, and any other UI library, the template opts not to use them, preferring Astro's own templating language, which uses vanilla JavaScript with no UI library.
(You may wonder why it makes this unpopular choice. A fair question! The team wanted to balance actual utility with broad approachability. Not everyone learning Stellar and Soroban is familiar with React, or any other UI library. It also demonstrates that core Soroban libraries all work with any JavaScript project.)
To use other templates, we will clone them from their repositories, and then copy these files into the root of the existing soroban-hello-world directory:
# For example, you could clone this repository
git clone https://github.com/stellar/soroban-examples
Now copy files into the root of soroban-hello-world.
So how can you find other valid frontend templates?
In GitHub, in the main search bar, search for "soroban-template-". With the quotes. Here's a direct link to the search results: github.com/search?q=%22soroban-template-%22
You can copy this approach for any other source code website, such as GitLab.
How do you know if any of these are any good? Try them. Look at their source code. How many stars do they have? How active are their maintainers? None of these are perfect metrics, which is why a curated registry might be nice in the future.
If none of them suit, then it might be time to...
Make your own template
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, and is a part of the template initialization; bindings for the hello world smart contract are created.
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.
1. Initialize a SolidJS project
npx degit solidjs/templates/ts soroban-template-solid
cd soroban-template-solid
npm install
npm run dev
The basic SolidJS is now running on localhost port 3000.
Dependencies
Most of the needed dependencies are already included by the SolidJS template, we just need to add three more:
npm install dotenv glob util
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
Since we are going to interact with the smart contract from the initialize.js script, let’s copy the smart contract code to the root of the SolidJS template directory. Since the initialize.js script is calling Stellar CLI commands, it will only work if the smart contract code is in the same directory as the script.
The root directory should look like this:
├── contracts
│ └── hello_world
│ ├── src
│ │ └── lib.rs
│ └── Cargo.toml
│ └── Makefile
├── node_modules
├── packages
├── src
│ ├── App.tsx
│ └── index.tsx
├── .env
├── index.html
├── tsconfig.json
├── vite.config.ts
├── initialize.js
├── package.json
└── Cargo.toml
2. Environment variables
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.
These are the variables needed:
STELLAR_NETWORK="testnet"
STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
STELLAR_ACCOUNT="my-user-name"
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.
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.
Definitions
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.
// Get directory names
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
// Define array to hold deployed smart contract info
var smartContracts = Array();
// Run exec commands asynchronously
const execAsync = promisify(exec);
User
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:
// ###################### Create User ########################
function createUser() {
execSync(
`stellar keys generate --fund ${process.env.STELLAR_ACCOUNT} | true`,
);
}
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.
You can check the new user’s public key by running this CLI command:
stellar keys public-key < my-user-name >
With the public key, you can look up the account on Stellar Expert.
Build contracts
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).
// Remove all previous build files
function removeFiles(pattern) {
glob(pattern).forEach((entry) => rmSync(entry));
}
function buildAll() {
removeFiles(`${dirname}/target/wasm32v1-none/release/*.wasm`);
removeFiles(`${dirname}/target/wasm32v1-none/release/*.d`);
execSync(`stellar contract build`);
console.log("Build complete");
}
The helper function removeFiles will delete any wasm or d files in the target directory.
Deploy contracts
Now that the smart contract has been built, we can deploy the smart contract to the network, so we can invoke the smart contract functions from any client, such as our SolidJS template.
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.
// 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(),
});
console.log(`Deployed ${filenameNoExtension(wasm)}`);
}
async function deployAll() {
console.log("Deploying all contracts");
const wasmFiles = glob(`${dirname}/target/wasm32v1-none/release/*.wasm`);
for (const wasm of wasmFiles) {
await deploy(wasm);
}
}
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.
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.
Create bindings
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()).
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)`);
}
async function bindAll() {
// Bind all deployed contracts
for (const contract of smartContracts) {
await bind(contract);
}
}
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 generate the bindings.
Import bindings
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.
The file is stored with the contract name as the file name, and with the .ts as the extension, e.g., hello_world.ts.
function importContract({ alias, wasm, contractid }) {
const outputDir = `${dirname}/src/contracts/`;
mkdirSync(outputDir, { recursive: true });
const importContent =
`import * as Client from '${alias}';\n` +
`export default new Client.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`;
const outputPath = `${outputDir}/${alias}.ts`;
writeFileSync(outputPath, importContent);
console.log(`Created import for ${alias}`);
}
function importAll() {
smartContracts.forEach(importContract);
}
Main function
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.
// Calling the functions in sequence
async function main() {
createUser();
buildAll();
await deployAll();
await bindAll();
importAll();
}
main().catch((e) => {
console.error("Initialization failed", e);
process.exit(1);
});
Complete initialize.js file
This is the complete file. Place it in the SolidJS root:
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";
// ###################### Definitions ########################
// Get directory names
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
// Define array to hold deployed smart contracts
var smartContracts = Array();
// Run exec commands asynchronously
const execAsync = promisify(exec);
// ###################### Create User ########################
function createUser() {
execSync(
`stellar keys generate --fund ${process.env.STELLAR_ACCOUNT} | true`,
);
}
// ###################### Build Contracts ########################
// Remove all previous build files
function removeFiles(pattern) {
glob(pattern).forEach((entry) => rmSync(entry));
}
function buildAll() {
removeFiles(`${dirname}/target/wasm32v1-none/release/*.wasm`);
removeFiles(`${dirname}/target/wasm32v1-none/release/*.d`);
execSync(`stellar contract build`);
console.log("Build complete");
}
// ###################### Deploy Contracts ########################
// 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.substring(0, stdout.length - 1),
});
console.log(`Deployed ${filenameNoExtension(wasm)}`);
}
async function deployAll() {
console.log("Deploying all contracts");
const wasmFiles = glob(`${dirname}/target/wasm32v1-none/release/*.wasm`);
for (const wasm of wasmFiles) {
await deploy(wasm);
}
}
// ###################### Create Bindings ########################
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)`);
}
async function bindAll() {
// Bind all deployed contracts
for (const contract of smartContracts) {
await bind(contract);
}
}
// ###################### Import Bindings ########################
function importContract({ alias, wasm, contractid }) {
const outputDir = `${dirname}/src/contracts/`;
mkdirSync(outputDir, { recursive: true });
const importContent =
`import * as Client from '${alias}';\n` +
`export default new Client.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`;
const outputPath = `${outputDir}/${alias}.ts`;
writeFileSync(outputPath, importContent);
console.log(`Created import for ${alias}`);
}
function importAll() {
smartContracts.forEach(importContract);
}
// ###################### Main ########################
// Calling the functions in sequence
async function main() {
createUser();
buildAll();
await deployAll();
await bindAll();
importAll();
}
main().catch((e) => {
console.error("Initialization failed", e);
process.exit(1);
});
4. Modify Vite config
SolidJS is using the build tool Vite, and we need to make a minor addition to the Vite configuration file (vite.config.ts) for the module exports to work. Add these lines to the config:
optimizeDeps: {
include: ['@stellar/stellar-sdk', 'hello_world'],
},
5. Build the frontend
The template is now ready to use the smart contract and its binding through the client. Let’s build a very simple dapp, a frontend for the Hello World smart contract, with a text input field and a send button. When a user enters a text string in the input field and clicks the send button, the contract function is invoked with the text string as the argument. The returned value, a string array, is displayed in the frontend.
Here’s an example of how the code could look the existing code in the src/App.tsx file with this code:
import type { Component } from 'solid-js';
import { createSignal } from "solid-js";
import helloWorld from './contracts/hello_world';
const App: Component = () => {
const [input, setInput] = createSignal('');
const [greeting, setGreeting] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
async function getGreeting(e?: Event) {
e?.preventDefault();
setError(null);
setLoading(true);
try {
const { result } = await helloWorld.hello({ to: input() || 'you' });
const greet = Array.isArray(result) ? result.join(' ') : String(result);
setGreeting(greet);
} catch (err: any) {
console.error(err);
setError(err?.message || 'Unknown error');
} finally {
setLoading(false);
}
}
return (
<div>
<h1>Hello Soroban Solid Template!</h1>
<form onSubmit={getGreeting}>
<input
type="text"
placeholder="Type your name..."
value={input()}
onInput={(e: any) => setInput(e.target.value)}
/>
<button type="submit" disabled={loading()}>{loading() ? 'Sending...' : 'Send'}</button>
</form>
{error() && <p style={{ color: 'red' }}>Error: {error()}</p>}
<p>{greeting()}</p>
</div>
);
};
export default App;
6. Try it out
We can now run the code with this command:
npm run dev
The URL for the dapp will be shown in the terminal, typically it’s http://localhost:3000 unless the port 3000 is already in use.