Skip to content
Merged
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
10 changes: 6 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ on:
env:
CARGO_TERM_COLOR: always
PKG_CONFIG_PATH: /usr/lib/pkgconfig
PUBLIC_STELLAR_NETWORK: local

jobs:
build:
Expand Down Expand Up @@ -129,6 +128,8 @@ jobs:
# Setting pipefail so a failed build's exit status isn't overwritten
# by a successful call of `tee`
set -o pipefail
# Released CLIs still need --build-clients; newer ones only note
# that it's redundant for a version 2 scaffold.yml
stellar-scaffold build --build-clients --network local 2>&1 \
| tee build_clients.log
- name: Check client generation summary
Expand Down Expand Up @@ -156,13 +157,14 @@ jobs:
echo "Expected $INDEX to exist and be non-empty after client generation."
exit 1
fi
# Count one exported const per contract
# Count one client constructor per contract; the shared `options`
# export is not a client
EXPECTED=$(find contracts -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
ACTUAL=$(grep -c "^export const " "$INDEX" || true)
ACTUAL=$(grep -c "^export const .* = new " "$INDEX" || true)
if [ "$ACTUAL" -ne "$EXPECTED" ]; then
echo "Expected $EXPECTED client(s) in $INDEX, found $ACTUAL."
echo "Partial client generation, check the \"Build with Scaffold\" step."
grep "^export const " "$INDEX" || echo "(no exports)"
grep "^export const .* = new " "$INDEX" || echo "(no clients)"
exit 1
fi
echo "Generated $ACTUAL client(s)."
Expand Down
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
target
.soroban

# project identity and alias files will be in .config/stellar
.config
# what `stellar scaffold build` deployed from this checkout. The directory
# ignores itself for git; listing it here keeps Prettier out of it too
.scaffold

# test snapshot folders from sample contracts
test_snapshots
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ npm start --workspace templates/react
npm start --workspace templates/svelte
```

Each runs `stellar scaffold watch --build-clients` alongside the dev server, so
you need the **Stellar CLI** and **Stellar Scaffold CLI** installed (the root
`npm start` prints install instructions if either is missing).
Each runs `stellar scaffold watch` alongside the dev server, so you need the
**Stellar CLI** and **Stellar Scaffold CLI** installed (the root `npm start`
prints install instructions if either is missing).

Shared code lives in `app-lib/`; changes there are picked up by every template
via the `@stellar-scaffold/app-lib` workspace package. Keep framework-specific
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ cd my-project
2. Set up your development environment:

```bash
# Copy and configure environment variables like STELLAR_NETWORK
# Copy environment variables (optional STELLAR_NETWORK, secrets)
cp .env.example .env

# Install frontend dependencies
Expand Down
117 changes: 75 additions & 42 deletions app-lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Networks } from "@creit.tech/stellar-wallets-kit"
import { z } from "zod"
import { options } from "./clients"

type NetworkType = "mainnet" | "testnet" | "futurenet" | "local"

Expand All @@ -8,71 +8,105 @@ interface Network {
label: string
passphrase: string
rpcUrl: string
horizonUrl: string
}

const envSchema = z.object({
PUBLIC_STELLAR_NETWORK: z.enum([
"PUBLIC",
"FUTURENET",
"TESTNET",
"LOCAL",
"STANDALONE", // deprecated in favor of LOCAL
] as const),
PUBLIC_STELLAR_NETWORK_PASSPHRASE: z.enum(Networks),
PUBLIC_STELLAR_RPC_URL: z.string(),
PUBLIC_STELLAR_HORIZON_URL: z.string(),
})
/**
* The Stellar networks app-lib knows how to link to and fund on, keyed by
* passphrase. Custom networks that `extends` one of these share its
* passphrase, so they resolve to it too.
*/
const KNOWN_NETWORKS = {
[Networks.PUBLIC]: "PUBLIC",
[Networks.TESTNET]: "TESTNET",
[Networks.FUTURENET]: "FUTURENET",
[Networks.STANDALONE]: "LOCAL",
} as const

const parsed = envSchema.safeParse(import.meta.env)
type StellarNetwork = (typeof KNOWN_NETWORKS)[keyof typeof KNOWN_NETWORKS]

const env: z.infer<typeof envSchema> = parsed.success
? parsed.data
: {
PUBLIC_STELLAR_NETWORK: "LOCAL",
PUBLIC_STELLAR_NETWORK_PASSPHRASE: Networks.STANDALONE,
PUBLIC_STELLAR_RPC_URL: "http://localhost:8000/rpc",
PUBLIC_STELLAR_HORIZON_URL: "http://localhost:8000",
}
/**
* The network the generated clients were built for. `stellar scaffold` writes
* `options` into `clients/index.ts` from the network selected in
* `scaffold.yml`, so the frontend can never disagree with the contracts it
* calls. An unrecognized passphrase is an error rather than a silent fallback
* to the local network.
*/
const stellarNetworkFor = (passphrase: string): StellarNetwork => {
if (passphrase in KNOWN_NETWORKS) {
return KNOWN_NETWORKS[passphrase as keyof typeof KNOWN_NETWORKS]
}
throw new Error(
`Unsupported network passphrase in generated clients: "${passphrase}". app-lib supports the local, testnet, futurenet, and mainnet passphrases.`,
)
}

export const stellarNetwork =
env.PUBLIC_STELLAR_NETWORK === "STANDALONE"
? "LOCAL"
: env.PUBLIC_STELLAR_NETWORK
export const networkPassphrase = env.PUBLIC_STELLAR_NETWORK_PASSPHRASE
export const networkPassphrase = options.networkPassphrase
export const rpcUrl = options.rpcUrl
// Plain HTTP is only allowed where `scaffold.yml` allows it (local by default)
export const allowHttp = options.allowHttp
export const stellarNetwork = stellarNetworkFor(networkPassphrase)

const stellarEncode = (str: string) => {
return str.replace(/\//g, "//").replace(/;/g, "/;")
}

export const labPrefix = () => {
/**
* Stellar Lab's network state for a transaction-dashboard link. Lab also wants
* a Horizon URL: SDF's public instance for the public networks, and the
* quickstart container's root (where its Horizon is served) for local.
*/
const labNetwork = (): {
host: string
id: string
label: string
horizonUrl: string
} => {
switch (stellarNetwork) {
case "LOCAL":
return `http://localhost:8000/lab/transaction-dashboard?$=network$id=custom&label=Custom&horizonUrl=${stellarEncode(horizonUrl)}&rpcUrl=${stellarEncode(rpcUrl)}&passphrase=${stellarEncode(networkPassphrase)};`
return {
host: "http://localhost:8000/lab",
id: "custom",
label: "Custom",
horizonUrl: new URL(rpcUrl).origin,
}
case "PUBLIC":
return `https://lab.stellar.org/transaction-dashboard?$=network$id=mainnet&label=Mainnet&horizonUrl=${stellarEncode(horizonUrl)}&rpcUrl=${stellarEncode(rpcUrl)}&passphrase=${stellarEncode(networkPassphrase)};`
case "TESTNET":
return `https://lab.stellar.org/transaction-dashboard?$=network$id=testnet&label=Testnet&horizonUrl=${stellarEncode(horizonUrl)}&rpcUrl=${stellarEncode(rpcUrl)}&passphrase=${stellarEncode(networkPassphrase)};`
return {
host: "https://lab.stellar.org",
id: "mainnet",
label: "Mainnet",
horizonUrl: "https://horizon.stellar.org",
}
case "FUTURENET":
return `https://lab.stellar.org/transaction-dashboard?$=network$id=futurenet&label=Futurenet&horizonUrl=${stellarEncode(horizonUrl)}&rpcUrl=${stellarEncode(rpcUrl)}&passphrase=${stellarEncode(networkPassphrase)};`
default:
return `https://lab.stellar.org/transaction-dashboard?$=network$id=testnet&label=Testnet&horizonUrl=${stellarEncode(horizonUrl)}&rpcUrl=${stellarEncode(rpcUrl)}&passphrase=${stellarEncode(networkPassphrase)};`
return {
host: "https://lab.stellar.org",
id: "futurenet",
label: "Futurenet",
horizonUrl: "https://horizon-futurenet.stellar.org",
}
case "TESTNET":
return {
host: "https://lab.stellar.org",
id: "testnet",
label: "Testnet",
horizonUrl: "https://horizon-testnet.stellar.org",
}
}
}

// NOTE: needs to be exported for contract files in this directory
export const rpcUrl = env.PUBLIC_STELLAR_RPC_URL
export const horizonUrl = env.PUBLIC_STELLAR_HORIZON_URL
export const labPrefix = () => {
const lab = labNetwork()
return `${lab.host}/transaction-dashboard?$=network$id=${lab.id}&label=${lab.label}&horizonUrl=${stellarEncode(lab.horizonUrl)}&rpcUrl=${stellarEncode(rpcUrl)}&passphrase=${stellarEncode(networkPassphrase)};`
}

const networkToId = (network: string): NetworkType => {
const networkToId = (network: StellarNetwork): NetworkType => {
switch (network) {
case "PUBLIC":
return "mainnet"
case "TESTNET":
return "testnet"
case "FUTURENET":
return "futurenet"
default:
case "LOCAL":
return "local"
}
}
Expand All @@ -82,5 +116,4 @@ export const network: Network = {
label: stellarNetwork.toLowerCase(),
passphrase: networkPassphrase,
rpcUrl: rpcUrl,
horizonUrl: horizonUrl,
}
4 changes: 1 addition & 3 deletions app-lib/friendbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ export function getFriendbotUrl(address: string) {
case "TESTNET":
return `https://friendbot.stellar.org/?addr=${address}`
default:
throw new Error(
`Unknown or unsupported PUBLIC_STELLAR_NETWORK for friendbot: ${stellarNetwork}`,
)
throw new Error(`Friendbot is not available on ${stellarNetwork}`)
}
}

Expand Down
2 changes: 1 addition & 1 deletion app-lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Clients in app-lib/clients/index.ts). Framework-specific code (providers,
// stores, UI components) stays in each template.

export * from "./env" // rpcUrl, networkPassphrase, stellarNetwork, horizonUrl, network, labPrefix
export * from "./env" // rpcUrl, networkPassphrase, allowHttp, stellarNetwork, network, labPrefix
export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState
export * from "./friendbot" // getFriendbotUrl
export * from "./subscription" // subscribeToEvents
Expand Down
3 changes: 1 addition & 2 deletions app-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.5.0",
"@stellar/stellar-sdk": "^16.1.0",
"zod": "^4.3.5"
"@stellar/stellar-sdk": "^16.1.0"
}
}
6 changes: 3 additions & 3 deletions app-lib/subscription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { xdr } from "@stellar/stellar-sdk"
import { Server, type Api } from "@stellar/stellar-sdk/rpc"
import { rpcUrl, stellarNetwork } from "./env"
import { allowHttp, rpcUrl } from "./env"

/** A Soroban contract event delivered to a subscription callback. Re-exported so
* templates depend only on @stellar-scaffold/app-lib, not @stellar/stellar-sdk. */
Expand All @@ -13,8 +13,8 @@ const paging: Record<
{ lastLedgerStart?: number; pagingToken?: string }
> = {}

// NOTE: Server is configured using envvars which shouldn't change during runtime
const server = new Server(rpcUrl, { allowHttp: stellarNetwork === "LOCAL" })
// Server is configured from the generated clients' options, which don't change at runtime
const server = new Server(rpcUrl, { allowHttp })

/**
* Subscribe to events for a given topic from a given contract. Returns a
Expand Down
84 changes: 42 additions & 42 deletions app-lib/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
StellarWalletsKit,
} from "@creit.tech/stellar-wallets-kit"
import { defaultModules } from "@creit.tech/stellar-wallets-kit/modules/utils"
import { Horizon } from "@stellar/stellar-sdk"
import { networkPassphrase, stellarNetwork } from "./env"
import { Keypair, rpc, xdr } from "@stellar/stellar-sdk"
import { allowHttp, networkPassphrase, rpcUrl, stellarNetwork } from "./env"

// These wallets work on Local and Futurenet while the rest enforce or fail on
// any networks beside Testnet and Mainnet. Filter below based on dApp config.
Expand Down Expand Up @@ -139,52 +139,52 @@ export const onWalletChange = (
}
}

function getHorizonHost(mode: string) {
switch (mode) {
case "LOCAL":
return "http://localhost:8000"
case "FUTURENET":
return "https://horizon-futurenet.stellar.org"
case "TESTNET":
return "https://horizon-testnet.stellar.org"
case "PUBLIC":
return "https://horizon.stellar.org"
default:
throw new Error(`Unknown Stellar network: ${mode}`)
}
}

const horizon = new Horizon.Server(getHorizonHost(stellarNetwork), {
allowHttp: stellarNetwork === "LOCAL",
})
// Server is configured from the generated clients' options, which don't change at runtime
const server = new rpc.Server(rpcUrl, { allowHttp })

const formatter = new Intl.NumberFormat()

export type MappedBalances = Record<string, Horizon.HorizonApi.BalanceLine>
/** One stroop is 10^-7 XLM. */
const STROOPS_PER_XLM = 10_000_000n

/**
* Format a stroop amount as XLM. Splits the integer and fractional parts in
* bigint so large balances don't lose stroops before formatting.
*/
const formatStroops = (stroops: bigint): string => {
const whole = stroops / STROOPS_PER_XLM
const fraction = (stroops % STROOPS_PER_XLM).toString().padStart(7, "0")
return formatter.format(Number(`${whole}.${fraction}`))
}

/**
* Balances keyed by asset. Only native XLM (`xlm`) is fetched: RPC reads
* ledger entries by key and can't list an account's trustlines.
*/
export type MappedBalances = { xlm?: { balance: string } }

export const fetchBalances = async (address: string) => {
/**
* Fetch the account's native XLM balance over RPC. An unfunded account has no
* ledger entry, so it resolves to `{}` and consumers see no `xlm` key. RPC
* failures are logged and also resolve to `{}`.
*/
export const fetchBalances = async (
address: string,
): Promise<MappedBalances> => {
try {
const { balances } = await horizon.accounts().accountId(address).call()
const mapped = balances.reduce((acc, b) => {
b.balance = formatter.format(Number(b.balance))
const key =
b.asset_type === "native"
? "xlm"
: b.asset_type === "liquidity_pool_shares"
? b.liquidity_pool_id
: `${b.asset_code}:${b.asset_issuer}`
acc[key] = b
return acc
}, {} as MappedBalances)
return mapped
const key = xdr.LedgerKey.account(
new xdr.LedgerKeyAccount({
accountId: Keypair.fromPublicKey(address).xdrPublicKey(),
}),
)
Comment thread
zachfedor marked this conversation as resolved.
Resolved
// `getLedgerEntries` rather than `getAccountEntry`, which reports every
// failure (including RPC outages) as "Account not found"
const { entries } = await server.getLedgerEntries(key)
const account = entries[0]?.val.account()
if (!account) return {}
return { xlm: { balance: formatStroops(account.balance().toBigInt()) } }
} catch (err) {
// `not found` is sort of expected, indicating an unfunded wallet, which
// the consumer of `balances` can understand via the lack of `xlm` key.
// If the error does NOT match 'not found', log the error.
// We should also possibly not return `{}` in this case?
if (!(err instanceof Error && err.message.match(/not found/i))) {
console.error(err)
}
console.error(err)
return {}
}
}
Expand Down
Loading
Loading