From 3fbae0ff0bd484e3b0d25536e2ce12ac730287f3 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 7 Oct 2026 11:58:07 -0300 Subject: [PATCH 1/2] feat(app-lib): generate the contract clients at build time --- AGENTS.md | 14 ++- app-lib/contracts.ts | 125 +++++----------------- app-lib/readContract.ts | 9 +- app/package.json | 3 +- app/src/buffer.d.ts | 5 + app/src/hooks/useInvestorRequests.test.ts | 15 ++- app/src/hooks/useInvestorRequests.ts | 3 +- app/src/hooks/useVaultAuthorities.ts | 8 +- package-lock.json | 1 + scripts/generate-addresses.mjs | 24 +---- scripts/generate-clients.mjs | 48 +++++++++ scripts/lib/environments.mjs | 46 ++++++++ scripts/lib/environments.test.mjs | 82 ++++++++++++++ vitest.config.ts | 8 ++ 14 files changed, 248 insertions(+), 143 deletions(-) create mode 100644 app/src/buffer.d.ts create mode 100644 scripts/generate-clients.mjs create mode 100644 scripts/lib/environments.mjs create mode 100644 scripts/lib/environments.test.mjs diff --git a/AGENTS.md b/AGENTS.md index c237f7f..013a248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,11 +75,15 @@ apply as the corresponding code lands here. clone builds the client before the app, or `tsc` cannot resolve the module. - `app-lib/clients/index.ts` is auto-generated and rewritten on every build or redeploy. Do not hand-edit it; customize by importing the client under `app/`. -- `app/` never imports the generated barrel — it constructs its own contract - clients at runtime with `contract.Client.from` (`app-lib/contracts.ts`), - bound to addresses `app/src/config/addresses.ts` generates offline. A fresh - clone can build and run `app` with no cargo, no stellar CLI, no scaffold, - and no local network. +- `app/` never imports the generated barrel. `app/`'s `prebuild` runs + `scripts/generate-clients.mjs`, which reads the contract ids and RPC URL of + `ADDRESSES_ENV` (default `staging`) from `environments.toml` and writes + `app-lib/clients//` from each contract's on-chain spec, with Node + alone. Those generated types are the only contract types; `app-lib/contracts.ts` + re-exports them and builds the runtime clients with `contract.Client.from`. + A fresh clone builds `app` with no cargo, no stellar CLI and no local network, + but needs RPC access to the selected network at build time. A local scaffold + build writes the same directory from the WASM. - When a tool must drive `cargo` directly rather than the CLI, set `SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1`. The build script fails without it and its error names neither the variable nor this way out, so diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index 34c896d..2e37885 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -1,111 +1,44 @@ import { AssembledTransaction, Client, - type MethodOptions, type SignTransaction, - type i128, - type u64, } from "@stellar/stellar-sdk/contract" import { network, networkPassphrase, rpcUrl } from "./env" - -type Call = (options?: MethodOptions) => Promise> -type CallOf = ( - args: A, - options?: MethodOptions, -) => Promise> - -export type Option = T | null +import type { Client as AssetApi } from "./clients/asset/src/index" +import type { + Client as AsyncVaultApi, + DepositRequest, + EpochInfo, + EpochStatus, + RedeemRequest, +} from "./clients/async_vault/src/index" +import type { Client as IdentityVerifierApi } from "./clients/identity_verifier/src/index" +import type { + Client as NavOracleApi, + NavReport, + OracleState, +} from "./clients/nav_oracle/src/index" +import type { Client as ShareTokenApi } from "./clients/share_token/src/index" + +export type { + AssetApi, + AsyncVaultApi, + DepositRequest, + EpochInfo, + EpochStatus, + IdentityVerifierApi, + NavOracleApi, + NavReport, + OracleState, + RedeemRequest, + ShareTokenApi, +} export interface Signer { publicKey: string signTransaction: SignTransaction } -export interface AsyncVaultApi { - liquid_reserve: Call - committed: Call - uncovered: Call - total_economic_supply: Call - net_deployed: Call - governance: Call> - manager: Call> - treasury: Call> - guardian: Call> - custodian: Call> - paused: Call - current_epoch: Call - get_epoch: CallOf<{ epoch_id: u64 }, Option> - get_deposit_request: CallOf< - { epoch_id: u64; controller: string }, - Option - > - get_redeem_request: CallOf< - { epoch_id: u64; controller: string }, - Option - > - request_deposit: CallOf<{ from: string; amount: i128 }, u64> - cancel_deposit: CallOf<{ from: string; epoch_id: u64 }, i128> - claim_deposit: CallOf<{ caller: string; epoch_id: u64 }, i128> - request_redeem: CallOf<{ from: string; shares: i128 }, u64> - cancel_redeem: CallOf<{ from: string; epoch_id: u64 }, i128> - claim_redeem: CallOf<{ caller: string; epoch_id: u64 }, i128> -} - -export type EpochStatus = - | { tag: "Open"; values: void } - | { tag: "Pending"; values: void } - | { tag: "Fulfilled"; values: void } - -export interface EpochInfo { - status: EpochStatus - total_deposited: i128 - total_shares_redeeming: i128 - share_price: i128 - closed_at: u64 - priceable_at: u64 -} - -export interface DepositRequest { - amount: i128 - claimed: boolean -} - -export interface RedeemRequest { - shares: i128 - claimed: boolean -} - -export type OracleState = - | { tag: "Valid"; values: void } - | { tag: "Stale"; values: void } - | { tag: "Paused"; values: void } - -export interface NavReport { - nav_per_share: i128 - expires_at: u64 - timestamp: u64 -} - -export interface NavOracleApi { - state: Call - latest: Call -} - -export interface IdentityVerifierApi { - is_allowed: CallOf<{ account: string }, boolean> -} - -export interface ShareTokenApi { - balance: CallOf<{ account: string }, i128> - symbol: Call - name: Call -} - -export interface AssetApi { - symbol: Call - balance: CallOf<{ id: string }, i128> -} - const clientOptions = (contractId: string, signer?: Signer) => ({ contractId, rpcUrl, diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index 7067cd0..dd0bfb3 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -4,7 +4,6 @@ import type { DepositRequest, EpochInfo, EpochStatus, - Option, RedeemRequest, } from "./contracts" import { rpcUrl, stellarNetwork } from "./env" @@ -127,7 +126,7 @@ function decodeLedgerEntry( key: xdr.LedgerKey, decode: (val: xdr.ScVal) => T, latestLedger: number | null, -): ContractRead> { +): ContractRead { if (!chunkResult.ok) return { kind: "unreadable" } const entry = chunkResult.byKey.get(key.toXDR("base64")) if (entry === undefined) return { kind: "value", value: null } @@ -154,9 +153,9 @@ const decodeEpochInfo = (val: xdr.ScVal): EpochInfo => { } export type EpochRequestsRead = { - epoch: ContractRead> - deposit: ContractRead> - redeem: ContractRead> + epoch: ContractRead + deposit: ContractRead + redeem: ContractRead } export async function readEpochRequests( diff --git a/app/package.json b/app/package.json index b8fd85f..74bbc63 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,7 @@ "dev": "npm start", "prestart": "node ../scripts/generate-addresses.mjs", "start": "concurrently --kill-others-on-fail --names stellar,vite -c gray,green --pad-prefix \"stellar scaffold watch --build-clients\" \"vite\"", - "prebuild": "node ../scripts/generate-addresses.mjs", + "prebuild": "node ../scripts/generate-addresses.mjs && node ../scripts/generate-clients.mjs", "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint .", @@ -26,6 +26,7 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "buffer": "6.0.3", "concurrently": "^10.0.3", "eslint": "^10.0.0", "globals": "^17.6.0", diff --git a/app/src/buffer.d.ts b/app/src/buffer.d.ts new file mode 100644 index 0000000..68456e9 --- /dev/null +++ b/app/src/buffer.d.ts @@ -0,0 +1,5 @@ +import { type Buffer as BufferPackage } from "buffer" + +declare global { + type Buffer = BufferPackage +} diff --git a/app/src/hooks/useInvestorRequests.test.ts b/app/src/hooks/useInvestorRequests.test.ts index 49ed898..b5209bb 100644 --- a/app/src/hooks/useInvestorRequests.test.ts +++ b/app/src/hooks/useInvestorRequests.test.ts @@ -4,7 +4,6 @@ import { type DepositRequest, type EpochInfo, type EpochRequestsRead, - type Option, type Price, type RedeemRequest, networkPassphrase, @@ -73,31 +72,31 @@ const fulfilledEpoch: EpochInfo = { priceable_at: 1_700_003_600n, } -const depositPresent: ContractRead> = { +const depositPresent: ContractRead = { kind: "value", value: { amount: 100_0000000n, claimed: false }, } -const depositClaimed: ContractRead> = { +const depositClaimed: ContractRead = { kind: "value", value: { amount: 100_0000000n, claimed: true }, } -const depositAbsent: ContractRead> = { +const depositAbsent: ContractRead = { kind: "value", value: null, } -const depositUnreadable: ContractRead> = { +const depositUnreadable: ContractRead = { kind: "unreadable", } -const depositArchived: ContractRead> = { +const depositArchived: ContractRead = { kind: "archived", } -const redeemPresent: ContractRead> = { +const redeemPresent: ContractRead = { kind: "value", value: { shares: 20_0000000n, claimed: false }, } -const redeemAbsent: ContractRead> = { +const redeemAbsent: ContractRead = { kind: "value", value: null, } diff --git a/app/src/hooks/useInvestorRequests.ts b/app/src/hooks/useInvestorRequests.ts index e96846c..d82f034 100644 --- a/app/src/hooks/useInvestorRequests.ts +++ b/app/src/hooks/useInvestorRequests.ts @@ -4,7 +4,6 @@ import { type DepositRequest, type EpochInfo, type EpochStatus, - type Option, type Price, type RedeemRequest, readContract, @@ -51,7 +50,7 @@ export type ClassifiedRequest = | { kind: "archived"; request: ArchivedRequest } export function classifyRequest( - read: ContractRead>, + read: ContractRead, side: RequestSide, epochId: bigint, epoch: EpochInfo, diff --git a/app/src/hooks/useVaultAuthorities.ts b/app/src/hooks/useVaultAuthorities.ts index b92b119..b0ede77 100644 --- a/app/src/hooks/useVaultAuthorities.ts +++ b/app/src/hooks/useVaultAuthorities.ts @@ -1,8 +1,4 @@ -import { - type ContractRead, - type Option, - readContract, -} from "@stellar-scaffold/app-lib" +import { type ContractRead, readContract } from "@stellar-scaffold/app-lib" import { useQuery } from "@tanstack/react-query" import { asyncVault } from "../config/clients" @@ -11,7 +7,7 @@ export type AuthorityKey = export type VaultAuthorities = Record -const toAddress = (read: ContractRead>): string | null => +const toAddress = (read: ContractRead): string | null => read.kind === "value" ? read.value : null async function fetchVaultAuthorities(): Promise { diff --git a/package-lock.json b/package-lock.json index fd5776f..850ccb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", + "buffer": "6.0.3", "concurrently": "^10.0.3", "eslint": "^10.0.0", "globals": "^17.6.0", diff --git a/scripts/generate-addresses.mjs b/scripts/generate-addresses.mjs index 268c0ca..dd32bcf 100644 --- a/scripts/generate-addresses.mjs +++ b/scripts/generate-addresses.mjs @@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs" import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" +import { contractIds, selectedEnvironment } from "./lib/environments.mjs" const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const source = resolve(repoRoot, "environments.toml") @@ -11,29 +12,12 @@ const target = resolve(repoRoot, "app/src/config/addresses.ts") // Only deployed environments declare their addresses here; a local one gets // them when it deploys, which is after this runs. -const environment = process.env.ADDRESSES_ENV ?? "staging" +const environment = selectedEnvironment(process.env) -const toml = readFileSync(source, "utf8") -const section = toml.split(`[${environment}.contracts]`)[1]?.split("\n[")[0] -if (section === undefined) { - throw new Error( - `environments.toml has no [${environment}.contracts] section. ` + - `Set ADDRESSES_ENV to an environment it declares.`, - ) -} - -const addresses = [ - ...section.matchAll(/^(\w+)\s*=\s*\{[^}]*id\s*=\s*"(C\w+)"/gm), -] -if (addresses.length === 0) { - throw new Error( - `[${environment}.contracts] declares no deployed addresses. ` + - `Deploy first, or set ADDRESSES_ENV to an environment that has them.`, - ) -} +const addresses = contractIds(readFileSync(source, "utf8"), environment) const entries = addresses - .map(([, name, id]) => `\t${name}: "${id}",`) + .map(({ name, id }) => `\t${name}: "${id}",`) .join("\n") mkdirSync(dirname(target), { recursive: true }) diff --git a/scripts/generate-clients.mjs b/scripts/generate-clients.mjs new file mode 100644 index 0000000..76ee895 --- /dev/null +++ b/scripts/generate-clients.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Generates a typed client per deployed contract from its on-chain spec, so a +// build needs Node and RPC access only, with no cargo or Stellar CLI. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { BindingGenerator, rpc } from "@stellar/stellar-sdk" +import { + contractIds, + networkOf, + selectedEnvironment, +} from "./lib/environments.mjs" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const toml = readFileSync(resolve(repoRoot, "environments.toml"), "utf8") +const environment = selectedEnvironment(process.env) +const contracts = contractIds(toml, environment) +const { rpcUrl, networkPassphrase, allowHttp } = networkOf(toml, environment) +const server = new rpc.Server(rpcUrl, { allowHttp }) + +const generate = async ({ name, id }) => { + const generator = await BindingGenerator.fromContractId(id, server) + const files = generator.generate({ + contractName: name, + contractAddress: id, + rpcUrl, + networkPassphrase, + }) + const dir = resolve(repoRoot, "app-lib/clients", name) + mkdirSync(resolve(dir, "src"), { recursive: true }) + const sources = { + "src/index.ts": files.index, + "src/types.ts": files.types, + "src/client.ts": files.client, + "package.json": files.packageJson.replace( + /"name": "[^"]+"/, + `"name": "${name}"`, + ), + "tsconfig.json": files.tsConfig, + } + for (const [path, content] of Object.entries(sources)) { + writeFileSync(resolve(dir, path), content) + } +} + +await Promise.all(contracts.map(generate)) + +console.log(`clients: ${contracts.length} from [${environment}.contracts]`) diff --git a/scripts/lib/environments.mjs b/scripts/lib/environments.mjs new file mode 100644 index 0000000..75cb46d --- /dev/null +++ b/scripts/lib/environments.mjs @@ -0,0 +1,46 @@ +// Reads one environment's network and deployed contract ids from environments.toml. + +export const selectedEnvironment = (env) => env.ADDRESSES_ENV ?? "staging" + +const section = (toml, header) => { + const body = toml.split(`[${header}]`)[1]?.split("\n[")[0] + if (body === undefined) { + throw new Error( + `environments.toml has no [${header}] section. ` + + `Set ADDRESSES_ENV to an environment it declares.`, + ) + } + return body +} + +export const contractIds = (toml, environment) => { + const body = section(toml, `${environment}.contracts`) + const ids = [...body.matchAll(/^(\w+)\s*=\s*\{[^}]*id\s*=\s*"(C\w+)"/gm)].map( + ([, name, id]) => ({ name, id }), + ) + if (ids.length === 0) { + throw new Error( + `[${environment}.contracts] declares no deployed addresses. ` + + `Deploy first, or set ADDRESSES_ENV to an environment that has them.`, + ) + } + return ids +} + +const value = (body, key, environment) => { + const found = body.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, "m"))?.[1] + if (found === undefined) { + throw new Error(`[${environment}.network] declares no ${key}.`) + } + return found +} + +export const networkOf = (toml, environment) => { + const body = section(toml, `${environment}.network`) + const rpcUrl = value(body, "rpc-url", environment) + return { + rpcUrl, + networkPassphrase: value(body, "network-passphrase", environment), + allowHttp: rpcUrl.startsWith("http://"), + } +} diff --git a/scripts/lib/environments.test.mjs b/scripts/lib/environments.test.mjs new file mode 100644 index 0000000..77ebd8a --- /dev/null +++ b/scripts/lib/environments.test.mjs @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest" +import { contractIds, networkOf, selectedEnvironment } from "./environments.mjs" + +const toml = ` +[staging.network] +rpc-url = "https://rpc.example" +network-passphrase = "Test Passphrase" + +[[staging.accounts]] +name = "user" + +[staging.contracts] +async_vault = { id = "CAAA" } +asset = { id = "CBBB", client = true } + +[development.network] +rpc-url = "http://localhost:8000/rpc" +network-passphrase = "Local Passphrase" + +[development.contracts] + +[development.contracts.compliance] +client = true +` + +describe("selectedEnvironment", () => { + it("defaults to staging", () => { + expect(selectedEnvironment({})).toBe("staging") + }) + + it("honours ADDRESSES_ENV", () => { + expect(selectedEnvironment({ ADDRESSES_ENV: "production" })).toBe( + "production", + ) + }) +}) + +describe("contractIds", () => { + it("lists every deployed contract with its id", () => { + expect(contractIds(toml, "staging")).toEqual([ + { name: "async_vault", id: "CAAA" }, + { name: "asset", id: "CBBB" }, + ]) + }) + + it("fails when the environment has no contracts section", () => { + expect(() => contractIds(toml, "production")).toThrow( + /no \[production\.contracts\] section/, + ) + }) + + it("fails when the section declares no deployed ids", () => { + expect(() => contractIds(toml, "development")).toThrow( + /\[development\.contracts\] declares no deployed addresses/, + ) + }) +}) + +describe("networkOf", () => { + it("reads the rpc url and passphrase of the environment", () => { + expect(networkOf(toml, "staging")).toEqual({ + rpcUrl: "https://rpc.example", + networkPassphrase: "Test Passphrase", + allowHttp: false, + }) + }) + + it("allows plain http only for a local rpc url", () => { + expect(networkOf(toml, "development").allowHttp).toBe(true) + }) + + it("fails when the environment has no network section", () => { + expect(() => networkOf(toml, "production")).toThrow( + /no \[production\.network\] section/, + ) + }) + + it("fails when the network section lacks a key", () => { + const partial = `[staging.network]\nrpc-url = "https://rpc.example"\n` + expect(() => networkOf(partial, "staging")).toThrow(/network-passphrase/) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 820172e..1a715da 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,14 @@ export default defineConfig({ server: { deps: { inline: [/@creit\.tech\/stellar-wallets-kit/] } }, }, }, + { + root: "./scripts", + test: { + name: "scripts", + environment: "node", + include: ["lib/**/*.test.mjs"], + }, + }, { root: "./app-lib", test: { From 9433814dd93bba080dcbc001de873c2878d330f9 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Wed, 7 Oct 2026 21:25:55 -0300 Subject: [PATCH 2/2] fix(app): pin the SDK types to the root copy for the generated clients --- app/tsconfig.app.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/tsconfig.app.json b/app/tsconfig.app.json index 71b6cb3..7c74a66 100644 --- a/app/tsconfig.app.json +++ b/app/tsconfig.app.json @@ -1,4 +1,17 @@ { "extends": "@theahaco/ts-config/typescript", + "compilerOptions": { + "paths": { + "@stellar/stellar-sdk": [ + "../node_modules/@stellar/stellar-sdk/lib/esm/index.d.ts" + ], + "@stellar/stellar-sdk/contract": [ + "../node_modules/@stellar/stellar-sdk/lib/esm/contract/index.d.ts" + ], + "@stellar/stellar-sdk/rpc": [ + "../node_modules/@stellar/stellar-sdk/lib/esm/rpc/index.d.ts" + ] + } + }, "include": ["src", "reset.d.ts"] }