Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4c8e19c
test: run the js tests against the generated clients
hpmaxi Nov 2, 2026
8ebf183
build: type-check app, app-lib and the harness
hpmaxi Nov 2, 2026
f242f33
ci: build the contracts and clients once before the js checks
hpmaxi Nov 2, 2026
105c76b
fix(ci): type-check app-lib with the app's compiler
hpmaxi Nov 2, 2026
c9c75ec
test(app): fake the contract clients with one typed helper
hpmaxi Nov 2, 2026
80acc8e
refactor(app): split TransactionModal into section modules
hpmaxi Nov 2, 2026
d1d371b
test(app): split the TransactionModal tests by section
hpmaxi Nov 2, 2026
cb280b7
build(harness): write every client with the SDK's generator
hpmaxi Nov 2, 2026
d3d900f
fix(app-lib): leave the clients' compiled output out of the type check
hpmaxi Nov 2, 2026
908a78b
test(app): make a faked read carry its result
hpmaxi Nov 2, 2026
bb9b2a3
ci: pass only the clients between jobs
hpmaxi Nov 2, 2026
df89ed1
test(app): move the modal render helpers beside the other test helpers
hpmaxi Nov 2, 2026
5fe9919
revert: test(app): move the modal render helpers beside the other tes…
hpmaxi Nov 2, 2026
536f7af
revert: test(app): make a faked read carry its result
hpmaxi Nov 2, 2026
378ddb8
revert: test(app): split the TransactionModal tests by section
hpmaxi Nov 2, 2026
f34e539
revert: refactor(app): split TransactionModal into section modules
hpmaxi Nov 2, 2026
76aeaa4
revert: test(app): fake the contract clients with one typed helper
hpmaxi Nov 2, 2026
ae1aad6
fix(scripts): require the clients' dist only before the type check
hpmaxi Nov 2, 2026
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
64 changes: 50 additions & 14 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,45 @@ jobs:
fi
echo "code=$code" | tee -a "$GITHUB_OUTPUT"

# Runs for docs-only changes too: the js tests read the README.
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
cache-bin: "false"
- name: Install the stellar CLI
run: scripts/ci/install-tools.sh stellar
- name: Build the contracts
run: stellar contract build
# A build path in the Wasm would tie its hash to the checkout location.
- name: Print the Wasm hashes and check no build path is embedded
run: |
sha256sum target/wasm32v1-none/release/*.wasm
if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then
echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out."
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- run: npm i -g npm@11.7.0
- run: npm ci
- name: Generate the TypeScript clients from the Wasm
run: npm run build:clients
# One path, so the artifact's root is the clients folder itself.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: clients
path: app-lib/clients/
if-no-files-found: error
retention-days: 7

js:
needs: contracts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -62,8 +100,14 @@ jobs:
- run: npm i -g npm@11.7.0
- run: npm --version
- run: npm ci
- name: Download the generated clients
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: clients
path: app-lib/clients
- run: npm run lint
- run: npm run format:check
- run: npm run typecheck
- run: npm run test

secret-scan:
Expand Down Expand Up @@ -163,14 +207,6 @@ jobs:
# The same cargo build Scaffold runs, so its build below reuses the Wasm.
- name: Build the contracts
run: stellar contract build
# A build path in the Wasm would tie its hash to the checkout location.
- name: Print the Wasm hashes and check no build path is embedded
run: |
sha256sum target/wasm32v1-none/release/*.wasm
if grep -l -a -E '/home/runner|/Users/|\.cargo/registry' target/wasm32v1-none/release/*.wasm; then
echo "These Wasm files embed a build path, so their hashes depend on where the repository is checked out."
exit 1
fi
- name: Build the vault without the wind-down minimum
run:
CARGO_TARGET_DIR=target/short-wind-down stellar contract build
Expand Down Expand Up @@ -295,8 +331,6 @@ jobs:
run: npm run deploy -w scripts/harness
env:
HARNESS_NETWORK: local
- name: Typecheck the harness workspace
run: npm run typecheck -w scripts/harness
- name: Run the vault happy path on the local network
run: npm run test:happy-path -w scripts/harness
env:
Expand All @@ -311,7 +345,7 @@ jobs:
HARNESS_NETWORK: local
- name: Keep the harness report when it fails
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: harness-runs
path: scripts/harness/runs/
Expand Down Expand Up @@ -344,22 +378,24 @@ jobs:
# The check branch protection requires. Rust and network skip only for docs; the secret scan runs on pull requests.
build:
if: always()
needs: [changes, js, secret-scan, rust-tests, rust-checks, network]
needs:
[changes, contracts, js, secret-scan, rust-tests, rust-checks, network]
runs-on: ubuntu-latest
steps:
- name: Check the results
env:
CODE: ${{ needs.changes.outputs.code }}
CHANGES: ${{ needs.changes.result }}
CONTRACTS: ${{ needs.contracts.result }}
JS: ${{ needs.js.result }}
EVENT: ${{ github.event_name }}
SECRET_SCAN: ${{ needs.secret-scan.result }}
RUST_TESTS: ${{ needs.rust-tests.result }}
RUST_CHECKS: ${{ needs.rust-checks.result }}
NETWORK: ${{ needs.network.result }}
run: |
echo "changes=$CHANGES js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE"
[ "$CHANGES" = success ] && [ "$JS" = success ] || exit 1
echo "changes=$CHANGES contracts=$CONTRACTS js=$JS event=$EVENT secret-scan=$SECRET_SCAN rust-tests=$RUST_TESTS rust-checks=$RUST_CHECKS network=$NETWORK code=$CODE"
[ "$CHANGES" = success ] && [ "$CONTRACTS" = success ] && [ "$JS" = success ] || exit 1
if [ "$EVENT" = pull_request ]; then [ "$SECRET_SCAN" = success ] || exit 1; fi
if [ "$CODE" != false ]; then
[ "$RUST_TESTS" = success ] && [ "$RUST_CHECKS" = success ] && [ "$NETWORK" = success ] || exit 1
Expand Down
24 changes: 17 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,14 @@ does not carry over. Read it for Soroban and OZ mechanics only.
enable an experimental `soroban-sdk` feature (`spec_shaking_v2`) that only
works through the CLI wrapper. The devshell pins the Stellar CLI it expects.
- **Tests:** `cargo test` from the repo root runs every workspace member against
the in-memory `Env`. There is no unit-test runner for `app/` or `app-lib/`;
`e2e/` runs Playwright separately. CI runs `cargo test --locked` on every pull
request except one that changes only Markdown files outside `docs/evidence/`:
that one skips the Rust and local-network jobs. The secret scan runs on
every pull request.
the in-memory `Env`. `npm run test` runs vitest over `app/`, `app-lib/` and
`scripts/`, and `npm run typecheck` checks `app/`, `app-lib/` and the
harness. Both use the generated contract clients, so run
`stellar contract build && npm run build:clients` first. `e2e/` runs
Playwright separately. CI runs `cargo test --locked` on every pull request
except one that changes only Markdown files outside `docs/evidence/`: that
one skips the Rust and local-network jobs. The secret scan runs on every pull
request.
- **Toolchain:** `nix develop` provides it, or rustup honours
`rust-toolchain.toml`.

Expand Down Expand Up @@ -97,11 +100,18 @@ apply as the corresponding code lands here.
re-exports them and builds each runtime client with `new Client(...)` from its
generated `src/index`, whose embedded spec means no page fetches a contract's
instance or Wasm. Vite dedupes `@stellar/stellar-sdk` so a client's nested copy
never reaches the bundle, and vitest aliases the generated modules to
`vitest.clients.ts`, since tests run without them.
never reaches the bundle.
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.
- The clients come from one generator, the SDK's `BindingGenerator`, which
types an empty `Option` as `null`. `npm run build:clients` and the harness
deploy write each client's `src/` from the local Wasm, with no network, and
compile it to the `dist/` the harness imports. A workspace build
(`npm run build --workspaces`) recompiles `dist/` from `src/`. The scaffold's
`--build-clients` writes the stellar CLI's bindings instead, which type an
empty `Option` as `undefined`, so run `npm run build:clients` again after it
before type-checking.
- When `cargo` itself builds a contract for the Wasm target, rather than
`stellar contract build` (a size check with
`cargo build --release --target wasm32v1-none`, for example), set
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ Formatting and secret-scanning run on commit via a `prek` hook — see
`.pre-commit-config.yaml` and run `prek install` once. CI also scans every
pull request's commits with the same gitleaks version.

### Console and script tests

The tests and the type check for `app/`, `app-lib/` and `scripts/` run against
the TypeScript clients generated from the contracts. Generate them once, and
again after a contract's interface changes:

```sh
npm ci
stellar contract build && npm run build:clients
npm run test # stops and names any client that is missing
npm run typecheck # app, app-lib and the harness
```

### Integration & On-Chain Tests

From a fresh clone to a vault set deployed on a local network. Run these inside
Expand Down
7 changes: 7 additions & 0 deletions app-lib/buffer.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { type Buffer as BufferPackage } from "buffer"

// The generated clients name `Buffer` as a global; in the browser it comes from
// the `buffer` package, as in the app.
declare global {
type Buffer = BufferPackage
}
8 changes: 5 additions & 3 deletions app-lib/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ClientOptions } from "@stellar/stellar-sdk/contract"
import {
Client as ContractClient,
type ClientOptions,
} from "@stellar/stellar-sdk/contract"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { Client as GeneratedClient } from "../vitest.clients"
import {
connectAsset,
connectAsyncVault,
Expand All @@ -12,7 +14,7 @@ import {
import { server } from "./rpc"

const optionsOf = (client: unknown): ClientOptions => {
if (!(client instanceof GeneratedClient))
if (!(client instanceof ContractClient))
throw new Error("not built from the generated bindings")
return client.options
}
Expand Down
12 changes: 8 additions & 4 deletions app-lib/multisig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import {
} from "@stellar/stellar-sdk"
import {
AssembledTransaction,
Client as ContractClient,
Spec,
type ClientOptions,
type MethodOptions,
} from "@stellar/stellar-sdk/contract"
import { Api } from "@stellar/stellar-sdk/rpc"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { Client as GeneratedClient } from "../vitest.clients"
import type { AccountSigners } from "./accounts"
import { connectAsyncVault } from "./contracts"
import { networkPassphrase } from "./env"
import {
Expand Down Expand Up @@ -89,12 +91,13 @@ const signedBy = (...keypairs: Keypair[]) => {
return tx
}

const signers = {
const signers: AccountSigners = {
signers: [
{ key: signerOne.publicKey(), weight: 1 },
{ key: signerTwo.publicKey(), weight: 2 },
],
thresholds: { low: 1, med: 3, high: 3 },
sequence: "1",
}

const resultArm = (code: string) =>
Expand Down Expand Up @@ -552,9 +555,10 @@ describe("transactionStatus", () => {
})

const timeoutOf = (client: unknown): number | undefined => {
if (!(client instanceof GeneratedClient))
if (!(client instanceof ContractClient))
throw new Error("not the generated vault client")
return client.options.timeoutInSeconds
// The client hands its options to each call, method defaults included.
return (client.options as ClientOptions & MethodOptions).timeoutInSeconds
}

describe("operator timeout", () => {
Expand Down
3 changes: 2 additions & 1 deletion app-lib/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"extends": "@theahaco/ts-config/typescript.app",
"include": ["."]
"include": ["."],
"exclude": ["clients/*/dist", "node_modules"]
}
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier . --write",
"typecheck": "tsc"
"typecheck": "tsc -b"
},
"dependencies": {
"@stellar-scaffold/app-lib": "*",
Expand Down
3 changes: 2 additions & 1 deletion app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "../app-lib" }
]
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
"format:check": "prettier . --check",
"build:clients": "npm run build:clients -w scripts/harness",
"seed": "npm run seed -w scripts/harness --",
"pretest": "node scripts/generate-addresses.mjs",
"test": "vitest run"
"pretest": "node scripts/clients-guard.mjs && node scripts/generate-addresses.mjs",
"test": "vitest run",
"pretypecheck": "node scripts/clients-guard.mjs --compiled && node scripts/generate-addresses.mjs",
"typecheck": "npm run typecheck -w app -w scripts/harness"
},
"workspaces": [
"app",
Expand Down
14 changes: 14 additions & 0 deletions scripts/clients-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { missingClients, missingClientsMessage } from "./lib/clients.mjs"

// Runs before the root `test`, and with `--compiled` before `typecheck`.
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")

const missing = missingClients(repoRoot, {
compiled: process.argv.includes("--compiled"),
})
if (missing.length > 0) {
console.error(missingClientsMessage(missing))
process.exit(1)
}
19 changes: 3 additions & 16 deletions scripts/generate-clients.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/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 { readFileSync } from "node:fs"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { BindingGenerator, rpc } from "@stellar/stellar-sdk"
import { writeClient } from "./lib/clients.mjs"
import {
contractIds,
networkOf,
Expand All @@ -26,21 +27,7 @@ const generate = async ({ name, 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)
}
writeClient(resolve(repoRoot, "app-lib/clients", name), files)
}

await Promise.all(contracts.map(generate))
Expand Down
17 changes: 14 additions & 3 deletions scripts/harness/build-clients.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { existsSync } from "node:fs"
import { join } from "node:path"
import { generateClient, rootSdkVersion } from "./lib/cli.js"

import { stellarAssetGenerator } from "./lib/asset-client.js"
import { generateClient, rootSdkVersion, wasmGenerator } from "./lib/cli.js"
import {
CONTRACT_NAMES,
REPO_ROOT,
Expand All @@ -10,7 +12,7 @@ import {
const wasmOf = (name: ContractName): string =>
join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`)

const clientDir = (name: ContractName): string =>
const clientDir = (name: ContractName | "asset"): string =>
join(REPO_ROOT, "app-lib", "clients", name)

async function main(): Promise<void> {
Expand All @@ -23,8 +25,17 @@ async function main(): Promise<void> {

const sdkVersion = rootSdkVersion()
for (const name of CONTRACT_NAMES) {
await generateClient(wasmOf(name), clientDir(name), sdkVersion)
await generateClient(
clientDir(name),
wasmGenerator(wasmOf(name)),
sdkVersion,
)
}
await generateClient(
clientDir("asset"),
await stellarAssetGenerator(),
sdkVersion,
)
console.log("clients generated")
}

Expand Down
Loading
Loading