The TypeScript half of SoroRail: the client SDK, the reference application, and the documentation site, in one pnpm workspace.
The contracts live in Sororail/sororail-contracts. Nothing here can be correct until those are, so that repo leads.
The contracts this talks to have not been audited. Do not use them with real value. No mainnet addresses will be published before an audit.
frontend/
├── packages/
│ └── sdk/ @sororail/sdk — typed client ✅ built
└── apps/
├── web/ Next.js reference application ✅ built
└── docs/ Astro Starlight documentation site ⬜ not started
| Piece | State |
|---|---|
packages/sdk |
Clients for all five contracts, typed error decoding, Freighter + keypair signers, amount helpers. 28 unit tests, 5 runnable examples, typechecks against @stellar/stellar-sdk 17.0.1. |
apps/web |
Overview, payroll, streams, vesting and escrow screens. Builds and serves; typechecks. Wallet-connected flows have not been click-tested against a real wallet. |
apps/docs |
Not started. |
Verified end-to-end against live testnet. examples/stream-lifecycle.ts
created and withdrew from a real stream on 2026-09-07 — transactions
520c753b
and
30d080d7
— and the contract's conservation invariant held on-chain.
Not yet verified live: the escrow, vesting, recurring and batch examples.
Only the stream one has been run by hand. All five now assert what they
demonstrate and are run by the scheduled integration job in
ci.yml, which deploys a fresh instance of each
contract to testnet — but that job has not yet reported a run, so treat those
four as unrun until it does.
Not yet done: the SDK is unpublished and the @sororail npm scope is not
reserved. The app has no indexer, no database and no history feed — it is
entirely client-side against Soroban RPC — and its wallet flows have not been
exercised with a real Freighter extension.
pnpm --filter @sororail/web devThen open http://localhost:3000. You will need Freighter and a funded testnet account.
The app is configured through environment variables, all optional:
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_RPC_URL |
https://soroban-testnet.stellar.org |
Soroban RPC endpoint. |
NEXT_PUBLIC_EXPLORER_URL |
derived from the RPC URL | Block explorer base for transaction and contract links, e.g. https://stellar.expert/explorer/testnet. |
Without NEXT_PUBLIC_EXPLORER_URL, an RPC on a testnet host links to the
testnet explorer; any other RPC (a local quickstart node, futurenet) gets no
explorer links rather than links that 404.
pnpm install
pnpm test # all packages
pnpm typecheck
pnpm build
pnpm changeset # describe a change for the next releaseNode ≥20. pnpm 10.
Runnable examples live in packages/sdk/examples —
they run against a real network and assert what they demonstrate, so they are
the SDK's integration tests. pnpm --filter @sororail/sdk test:integration
runs all five; see their README for setup.
Releases are managed with Changesets.
A PR that changes the SDK's published behaviour adds a changeset
(pnpm changeset); cutting a release (pnpm version-packages) turns the
pending ones into an entry in
packages/sdk/CHANGELOG.md, which also ships in
the npm package. Nothing has been released yet, so the changelog is empty and
upcoming changes are the files in .changeset/. The reference
app is not published and has no changelog. See
.changeset/README.md for the release steps.
import {
StreamClient,
KeypairSigner,
ContractError,
NetworkError,
SigningError,
ValidationError,
} from "@sororail/sdk";
import { Networks } from "@stellar/stellar-sdk";
const signer = new KeypairSigner(process.env.SECRET_KEY!);
const stream = new StreamClient({
contractId: "CBEE4SRXRGCJDWXP6DDOSX6FR4S2PJ5KHUQCHI3ABY3SQTCHYSA7CGC7",
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: Networks.TESTNET,
publicKey: signer.publicKey,
});
try {
// Build and inspect before anyone signs.
const call = await stream.withdraw();
const wouldReceive = await call.simulate();
// Then commit.
const { hash, result } = await call.signAndSend(signer);
} catch (error) {
if (error instanceof ContractError) {
// The contract refused. `message` is a sentence to show a person;
// branch on the variant (or the stable numeric `code`) to recover.
if (error.is("StreamInsufficientAccrued")) {
// Asked for more than has accrued: withdraw less, or wait.
}
} else if (error instanceof ValidationError) {
// Rejected before anything was sent. Fix the arguments.
} else if (error instanceof SigningError) {
// No wallet, locked, declined, or on the wrong network.
} else if (error instanceof NetworkError) {
// Could not simulate or submit. The original failure is `error.cause`.
} else {
throw error;
}
}Every stage can fail, so the whole flow sits in one try:
| Error | Thrown by | What it means |
|---|---|---|
ValidationError |
building a call (stream.withdraw() etc.) |
Bad arguments, or a client without a publicKey. Nothing was sent. |
ContractError |
simulate(), signAndSend() |
The contract refused. Carries code, variant, contract and a readable message. |
SigningError |
signAndSend() |
The signer could not sign: no wallet, locked, declined, a switched account, or NetworkMismatchError for the wrong network. |
NetworkError |
any stage | The RPC could not be reached or rejected the request; the original failure is cause. |
All of them extend SororailError, so anything else is a bug in the calling
code rather than a failed call. The reference app's
Feedback.tsx and
recovery.ts show one way to turn these into
UI: the message says what happened, the recovery text says what to do next.
Signing is injected, never performed here. The SDK builds and submits
transactions but never holds a secret key. Supply a Signer — FreighterSigner
in a browser, KeypairSigner for tests and scripts. FreighterSigner re-reads
the extension's selected account and network before every signature and
refuses — with NetworkMismatchError for the wrong network — rather than
signing as an account or on a network the transaction was not built for.
Every stage is separately accessible. prepare → simulate →
signAndSend, rather than one opaque call. A confirmation screen can only be
honest if it can show what a transaction will do before asking for a
signature, so nothing collapses those steps.
Amounts are bigint, never number. Number loses integer precision above
2^53, which is well inside the range of a real balance. Use toStroops /
fromStroops at the edges — and note the decimals trap: 7 is the default
for classic Stellar assets, but a custom token can declare anything, so read
decimals() off the token rather than assuming.
Errors are decoded, never bare codes. ContractError carries the ABI
integer, the variant name, the owning contract, and a message written for a
person. The code table mirrors sororail_common::errors and is tested against
its reserved ranges.
Zero framework dependencies. No React. If React helpers are ever wanted they
go in a separate packages/react.
Recurring charges do not accrue retroactively. A payee who forgets to charge for three months cannot then take three payments — the next charge is scheduled from now, and skipped periods are gone. This is deliberate consumer protection, and it surprises merchants. Say so in the UI.
Batch payouts are capped and all-or-nothing. Read
BatchPayoutClient.maxRecipients() rather than hardcoding, and use
BatchPayoutClient.chunk() to split a larger payroll. If any transfer fails,
nobody is paid.
See CONTRIBUTING.md.
Apache-2.0. See LICENSE.