Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,16 @@ After scaffolding a project, your folder structure will look like this:
my-project/
├── contracts/ # Rust smart contracts (compiled to WASM)
├── packages/ # Auto-generated TypeScript contract clients
├── src/ # React frontend code
│ ├── components/ # Reusable UI pieces
│ ├── contracts/ # Contract interaction logic
│ ├── App.tsx # Main app component
│ └── main.tsx # Entry point
├── app/ # Frontend application
| ├── package.json # Frontend packages
│ └── src/ # React source code
│ ├── components/ # Reusable UI pieces
│ ├── contracts/ # Contract interaction logic
│ ├── App.tsx # Main app component
│ └── main.tsx # Entry point
├── environments.toml # Configuration per environment (dev/test/prod)
├── .env # Local environment variables
├── package.json # Frontend packages
├── package.json # Workspace packages
├── target/ # Build outputs
```

Expand Down
21 changes: 17 additions & 4 deletions crates/stellar-scaffold-cli/src/commands/build/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ impl ScaffoldEnv {
}
}

/// Directory where npm workspace packages are generated
pub const PACKAGES_DIR: &str = "packages";
/// Directory where contract clients are generated
pub const CONTRACTS_DIR: &str = "app/src/contracts";

impl std::fmt::Display for ScaffoldEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", format!("{self:?}").to_lowercase())
Expand Down Expand Up @@ -245,6 +250,7 @@ impl Builder {
contract_id: &str,
network: &network::Network,
) -> Result<(), Error> {
let printer = self.printer();
let allow_http = if self.stellar_scaffold_env().testing_or_development() {
"\n allowHttp: true,"
} else {
Expand All @@ -263,7 +269,14 @@ export default new Client.Client({{
}});
"
);
let path = self.workspace_root.join(format!("src/contracts/{name}.ts"));
let path = self
.workspace_root
.join(format!("{CONTRACTS_DIR}/{name}.ts"));

printer.checkln(format!(
"Creating client instance in {}",
path.to_string_lossy()
));
std::fs::write(path, template)?;
Ok(())
}
Expand All @@ -273,10 +286,10 @@ export default new Client.Client({{
let printer = self.printer();
printer.infoln(format!("Binding {name:?} contract"));
let workspace_root = &self.workspace_root;
let final_output_dir = workspace_root.join(format!("packages/{name}"));
let final_output_dir = workspace_root.join(format!("{PACKAGES_DIR}/{name}"));

// Create a temporary directory for building the new client
let temp_dir = workspace_root.join(format!("target/packages/{name}"));
let temp_dir = workspace_root.join(format!("target/{PACKAGES_DIR}/{name}"));
let temp_dir_display = temp_dir.display();
let config_dir = self.get_config_dir()?;
self.run_against_rpc_server(cli::contract::bindings::typescript::Cmd::parse_arg_vec(&[
Expand Down Expand Up @@ -574,7 +587,7 @@ export default new Client.Client({{
}

fn get_package_dir(&self, name: &str) -> Result<std::path::PathBuf, Error> {
let package_dir = self.workspace_root.join(format!("packages/{name}"));
let package_dir = self.workspace_root.join(format!("{PACKAGES_DIR}/{name}"));
if !package_dir.exists() {
return Err(Error::BadContractName(name.to_string()));
}
Expand Down
47 changes: 28 additions & 19 deletions crates/stellar-scaffold-cli/tests/it/build_clients/contracts.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use stellar_scaffold_cli::commands::build::clients::{CONTRACTS_DIR, PACKAGES_DIR};
use stellar_scaffold_test::{rpc_url, AssertExt, TestEnv};

#[test]
Expand Down Expand Up @@ -44,11 +45,11 @@ soroban_token_contract.client = false
assert!(stderr.contains(&format!("Binding \"{c}\" contract")));

// check that contracts are actually deployed, bound, and imported
assert!(env.cwd.join(format!("packages/{c}")).exists());
assert!(env.cwd.join(format!("src/contracts/{c}.ts")).exists());
assert!(env.cwd.join(format!("{PACKAGES_DIR}/{c}")).exists());
assert!(env.cwd.join(format!("{CONTRACTS_DIR}/{c}.ts")).exists());

// check dist/index.js and dist/index.d.ts exist after npm run build
let dist_dir = env.cwd.join(format!("packages/{c}/dist"));
let dist_dir = env.cwd.join(format!("{PACKAGES_DIR}/{c}/dist"));
assert!(
dist_dir.join("index.js").exists(),
"index.js missing for {c}"
Expand Down Expand Up @@ -97,8 +98,8 @@ soroban_token_contract.client = false
assert!(stderr.contains(&format!("Binding \"{c}\" contract")));

// check that contracts are actually deployed, bound, and imported
assert!(env.cwd.join(format!("packages/{c}")).exists());
assert!(env.cwd.join(format!("src/contracts/{c}.ts")).exists());
assert!(env.cwd.join(format!("{PACKAGES_DIR}/{c}")).exists());
assert!(env.cwd.join(format!("{CONTRACTS_DIR}/{c}.ts")).exists());
}
});
}
Expand Down Expand Up @@ -323,11 +324,11 @@ soroban_token_contract.client = false
// Check that the contract files are created in the new directory
assert!(env
.cwd
.join("packages/soroban_hello_world_contract")
.join(format!("{PACKAGES_DIR}/soroban_hello_world_contract"))
.exists());
assert!(env
.cwd
.join("src/contracts/soroban_hello_world_contract.ts")
.join(format!("{CONTRACTS_DIR}/soroban_hello_world_contract.ts"))
.exists());
}

Expand Down Expand Up @@ -378,15 +379,17 @@ soroban_token_contract.client = false
// Check that contract client files are still generated
assert!(env
.cwd
.join("packages/soroban_hello_world_contract")
.join(format!("{PACKAGES_DIR}/soroban_hello_world_contract"))
.exists());
assert!(env
.cwd
.join("src/contracts/soroban_hello_world_contract.ts")
.join(format!("{CONTRACTS_DIR}/soroban_hello_world_contract.ts"))
.exists());

// Check dist/index.js and dist/index.d.ts exist after npm run build
let dist_dir = env.cwd.join("packages/soroban_hello_world_contract/dist");
let dist_dir = env
.cwd
.join(format!("{PACKAGES_DIR}/soroban_hello_world_contract/dist"));
assert!(
dist_dir.join("index.js").exists(),
"index.js missing for soroban_hello_world_contract"
Expand Down Expand Up @@ -447,7 +450,7 @@ soroban_auth_contract.client = false
[development.contracts.soroban_token_contract]
client = true
constructor_args = """
STELLAR_ACCOUNT=bob --symbol ABND --decimal 7 --name abundance --admin bb
STELLAR_ACCOUNT=bob --symbol ABND --decimal 7 --name abundance --admin bb
"""
"#,
rpc_url()
Expand Down Expand Up @@ -477,31 +480,37 @@ STELLAR_ACCOUNT=bob --symbol ABND --decimal 7 --name abundance --admin bb
// Check that successful contracts are still deployed
assert!(env
.cwd
.join("packages/soroban_hello_world_contract")
.join(format!("{PACKAGES_DIR}/soroban_hello_world_contract"))
.exists());
assert!(env
.cwd
.join(format!("{PACKAGES_DIR}/soroban_increment_contract"))
.exists());
assert!(env.cwd.join("packages/soroban_increment_contract").exists());
assert!(env
.cwd
.join("packages/soroban_custom_types_contract")
.join(format!("{PACKAGES_DIR}/soroban_custom_types_contract"))
.exists());
assert!(env
.cwd
.join("src/contracts/soroban_hello_world_contract.ts")
.join(format!("{CONTRACTS_DIR}/soroban_hello_world_contract.ts"))
.exists());
assert!(env
.cwd
.join("src/contracts/soroban_increment_contract.ts")
.join(format!("{CONTRACTS_DIR}/soroban_increment_contract.ts"))
.exists());
assert!(env
.cwd
.join("src/contracts/soroban_custom_types_contract.ts")
.join(format!("{CONTRACTS_DIR}/soroban_custom_types_contract.ts"))
.exists());

// Failed contract should not have generated client files
assert!(!env.cwd.join("packages/soroban_token_contract").exists());
assert!(!env
.cwd
.join("src/contracts/soroban_token_contract.ts")
.join(format!("{PACKAGES_DIR}/soroban_token_contract"))
.exists());
assert!(!env
.cwd
.join(format!("{CONTRACTS_DIR}/soroban_token_contract.ts"))
.exists());
});
}
9 changes: 5 additions & 4 deletions crates/stellar-scaffold-cli/tests/it/build_clients/watch.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::process::Stdio;
use stellar_scaffold_cli::commands::build::clients::CONTRACTS_DIR;
use stellar_scaffold_test::{rpc_url, TestEnv};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_stream::{wrappers::LinesStream, StreamExt};
Expand Down Expand Up @@ -337,9 +338,9 @@ async fn watch_and_vite_integration_test() {

// Try to request the actual JavaScript modules that would cause import errors
let js_module_paths = [
"/src/contracts/fungible_token_interface_example.ts",
"/src/contracts/nft_enumerable_example.ts",
"/src/contracts/stellar_hello_world_contract.ts",
format!("{CONTRACTS_DIR}/fungible_token_interface_example.ts"),
format!("{CONTRACTS_DIR}/nft_enumerable_example.ts"),
format!("{CONTRACTS_DIR}/stellar_hello_world_contract.ts"),
];

for module_path in js_module_paths {
Expand Down Expand Up @@ -399,7 +400,7 @@ mod test;
let mut new_vite_errors = Vec::new();

let client = reqwest::Client::new();
let hello_world_client_path = "/src/contracts/stellar_hello_world_contract.ts";
let hello_world_client_path = "{CONTRACTS_DIR}/stellar_hello_world_contract.ts";

// Monitor for 60 seconds for the rebuild process
let rebuild_timeout = tokio::time::Duration::from_secs(60);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@ packages/*
!packages/.gitkeep

# generated contract client imports
src/contracts/*
!src/contracts/util.ts
app/src/contracts/*
!app/src/contracts/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ If you look in [package.json](./package.json), you'll see that the `start` & `de
1. Deploys to a local network (_needs to be running with `docker run` or `soroban network start`_)
2. Saves contract IDs to `.soroban/contract-ids`
3. Generates TS bindings for each into the `packages` folder, which is set up as an [npm workspace](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#workspaces)
4. Create a file in `src/contracts` that imports the contract client and initializes it for the `local` network.
4. Create a file in `app/src/contracts` that imports the contract client and initializes it for the `local` network.

You're now ready to import these initialized contract clients in your [Astro templates](https://docs.astro.build/en/core-concepts/astro-syntax/) or your [React, Svelte, Vue, Alpine, Lit, and whatever else JS files](https://docs.astro.build/en/core-concepts/framework-components/#official-ui-framework-integrations). You can see an example of this in [index.astro](./src/pages/index.astro).
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import 'dotenv/config';
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import "dotenv/config";
import { mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
import { execSync } from "child_process";
import path from "path";
import { fileURLToPath } from "url";

// Load environment variables starting with PUBLIC_ into the environment,
// so we don't need to specify duplicate variables in .env
for (const key in process.env) {
if (key.startsWith('PUBLIC_')) {
if (key.startsWith("PUBLIC_")) {
process.env[key.substring(7)] = process.env[key];
}
}
Expand All @@ -16,11 +16,11 @@ for (const key in process.env) {
// the Genesis accounts for each of the "typical" networks, and should work as
// a valid, funded network account.
const GENESIS_ACCOUNTS = {
public: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7',
testnet: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H',
futurenet: 'GADNDFP7HM3KFVHOQBBJDBGRONMKQVUYKXI6OYNDMS2ZIK7L6HA3F2RF',
local: 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI',
}
public: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7",
testnet: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H",
futurenet: "GADNDFP7HM3KFVHOQBBJDBGRONMKQVUYKXI6OYNDMS2ZIK7L6HA3F2RF",
local: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI",
};

console.log("###################### Initializing ########################");

Expand All @@ -29,12 +29,12 @@ const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);

// variable for later setting pinned version of soroban in "$(dirname/target/bin/soroban)"
const soroban = "soroban"
const soroban = "soroban";

// Function to execute and log shell commands
function exe(command) {
console.log(command);
execSync(command, { stdio: 'inherit' });
execSync(command, { stdio: "inherit" });
}

function fund_all() {
Expand All @@ -53,40 +53,47 @@ function filenameNoExtension(filename) {
}

function deploy(wasm) {
exe(`(${soroban} contract deploy --wasm ${wasm} --ignore-checks) > ${dirname}/.soroban/contract-ids/${filenameNoExtension(wasm)}.txt`);
exe(
`(${soroban} contract deploy --wasm ${wasm} --ignore-checks) > ${dirname}/.soroban/contract-ids/${filenameNoExtension(wasm)}.txt`,
);
}

function deploy_all() {
const contractsDir = `${dirname}/.soroban/contract-ids`;
mkdirSync(contractsDir, { recursive: true });

const wasmFiles = readdirSync(`${dirname}/target/wasm32-unknown-unknown/release`).filter(file => file.endsWith('.wasm'));
const wasmFiles = readdirSync(
`${dirname}/target/wasm32-unknown-unknown/release`,
).filter((file) => file.endsWith(".wasm"));

wasmFiles.forEach(wasmFile => {
wasmFiles.forEach((wasmFile) => {
deploy(`${dirname}/target/wasm32-unknown-unknown/release/${wasmFile}`);
});
}

function bind(contract) {
const filenameNoExt = filenameNoExtension(contract);
exe(`${soroban} contract bindings typescript --contract-id $(cat ${contract}) --output-dir ${dirname}/packages/${filenameNoExt} --overwrite`);
exe(
`${soroban} contract bindings typescript --contract-id $(cat ${contract}) --output-dir ${dirname}/packages/${filenameNoExt} --overwrite`,
);
}

function bind_all() {
const contractIdsDir = `${dirname}/.soroban/contract-ids`;
const contractFiles = readdirSync(contractIdsDir);

contractFiles.forEach(contractFile => {
contractFiles.forEach((contractFile) => {
const contractPath = path.join(contractIdsDir, contractFile);
if (statSync(contractPath).size > 0) { // Check if file is not empty
if (statSync(contractPath).size > 0) {
// Check if file is not empty
bind(contractPath);
}
});
}

function importContract(contract) {
const filenameNoExt = filenameNoExtension(contract);
const outputDir = `${dirname}/src/contracts/`;
const outputDir = `${dirname}/app/src/contracts/`;
mkdirSync(outputDir, { recursive: true });

const importContent =
Expand All @@ -95,7 +102,7 @@ function importContract(contract) {
`export default new Client.Client({\n` +
` ...Client.networks.${process.env.SOROBAN_NETWORK},\n` +
` rpcUrl,\n` +
`${process.env.SOROBAN_NETWORK === 'local' || 'standalone' ? ` allowHttp: true,\n` : null}` +
`${process.env.SOROBAN_NETWORK === "local" || "standalone" ? ` allowHttp: true,\n` : null}` +
` publicKey: '${GENESIS_ACCOUNTS[process.env.SOROBAN_NETWORK]}',\n` +
`});\n`;

Expand All @@ -108,9 +115,10 @@ function import_all() {
const contractIdsDir = `${dirname}/.soroban/contract-ids`;
const contractFiles = readdirSync(contractIdsDir);

contractFiles.forEach(contractFile => {
contractFiles.forEach((contractFile) => {
const contractPath = path.join(contractIdsDir, contractFile);
if (statSync(contractPath).size > 0) { // Check if file is not empty
if (statSync(contractPath).size > 0) {
// Check if file is not empty
importContract(contractPath);
}
});
Expand Down