From 8e7436cc204d653fe99b6286e2fc28ce4e5cb898 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 17:48:21 -0300 Subject: [PATCH 1/5] fix(app): use ledger time for the attestation expiry Closes #416 --- app/src/hooks/useAttest.test.ts | 72 +++++++++++++++++++++++++++++++-- app/src/hooks/useAttest.ts | 48 +++++++++++++++------- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 65cd994c..2a803e6c 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -1,16 +1,26 @@ import { Buffer } from "buffer" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" +import type * as AppLib from "@stellar-scaffold/app-lib" import { type Price } from "@stellar-scaffold/app-lib" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { attestWriteKeys, useAttest } from "./useAttest" +import { cycleReadsKey } from "./useCycleState" import { investorRequestsKey } from "./useInvestorRequests" import { navLatestKey } from "./useNavPrice" -const { oracleMock, navOracleWriterMock } = vi.hoisted(() => ({ - oracleMock: { attest: vi.fn() }, - navOracleWriterMock: vi.fn(), +const { oracleMock, navOracleWriterMock, readLedgerTimeMock } = vi.hoisted( + () => ({ + oracleMock: { attest: vi.fn() }, + navOracleWriterMock: vi.fn(), + readLedgerTimeMock: vi.fn(), + }), +) + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + readLedgerTime: readLedgerTimeMock, })) vi.mock("../config/clients", () => ({ @@ -40,6 +50,7 @@ describe("useAttest", () => { vi.clearAllMocks() vi.spyOn(Date, "now").mockReturnValue(wallClock * 1000 + 999) navOracleWriterMock.mockResolvedValue(oracleMock) + readLedgerTimeMock.mockResolvedValue(null) }) it("moves through awaiting signature, submitted, then confirmed, in order, expiring the report a freshness window after the wall clock", async () => { @@ -86,6 +97,61 @@ describe("useAttest", () => { }) }) + it("expires the report a freshness window after the cached ledger time when the wall clock is behind it", async () => { + const ledgerTime = BigInt(wallClock) + 3_600n + oracleMock.attest.mockResolvedValue(successful()) + const { result, queryClient } = renderAttest() + queryClient.setQueryData(cycleReadsKey, { ledgerTime }) + + await act(() => result.current.submit(attestation)) + + expect(oracleMock.attest).toHaveBeenCalledWith({ + input: expect.objectContaining({ + expires_at: ledgerTime + attestation.freshness, + }), + caller: investorAddress, + }) + expect(readLedgerTimeMock).not.toHaveBeenCalled() + }) + + it("advances a cached ledger time by the time elapsed since it was read", async () => { + const ledgerTime = BigInt(wallClock) + 3_600n + const elapsed = 600 + oracleMock.attest.mockResolvedValue(successful()) + const { result, queryClient } = renderAttest() + queryClient.setQueryData( + cycleReadsKey, + { ledgerTime }, + { updatedAt: Date.now() - elapsed * 1000 }, + ) + + await act(() => result.current.submit(attestation)) + + expect(oracleMock.attest).toHaveBeenCalledWith({ + input: expect.objectContaining({ + expires_at: ledgerTime + BigInt(elapsed) + attestation.freshness, + }), + caller: investorAddress, + }) + }) + + it("reads ledger time once when none is cached", async () => { + const ledgerTime = BigInt(wallClock) + 3_600n + readLedgerTimeMock.mockResolvedValue(ledgerTime) + oracleMock.attest.mockResolvedValue(successful()) + const { result } = renderAttest() + + await act(() => result.current.submit(attestation)) + + expect(oracleMock.attest).toHaveBeenCalledWith({ + input: expect.objectContaining({ + expires_at: ledgerTime + attestation.freshness, + }), + caller: investorAddress, + }) + expect(readLedgerTimeMock).toHaveBeenCalledTimes(1) + }) + it("sends an empty proof URI when the attester gives none", async () => { oracleMock.attest.mockResolvedValue(successful()) const { result } = renderAttest() diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts index 35f974a3..3803e5bf 100644 --- a/app/src/hooks/useAttest.ts +++ b/app/src/hooks/useAttest.ts @@ -1,5 +1,10 @@ import { Buffer } from "buffer" -import { type Price, type Signer } from "@stellar-scaffold/app-lib" +import { + type Price, + readLedgerTime, + type Signer, +} from "@stellar-scaffold/app-lib" +import { useQueryClient } from "@tanstack/react-query" import { useCallback } from "react" import { navOracleWriter } from "../config/clients" import { @@ -7,7 +12,7 @@ import { type TransactionStatus, useContractTransaction, } from "./useContractTransaction" -import { cycleWriteKeys } from "./useCycleState" +import { type CycleReads, cycleReadsKey, cycleWriteKeys } from "./useCycleState" import { navLatestKey } from "./useNavPrice" export type AttestStatus = TransactionStatus @@ -29,20 +34,33 @@ export interface UseAttest { } export function useAttest(): UseAttest { + const queryClient = useQueryClient() const call = useCallback( - (signer: Signer, { price, freshness, proofHash, proofUri }: Attestation) => - navOracleWriter(signer).then((oracle) => - oracle.attest({ - input: { - nav_per_share: price, - expires_at: BigInt(Math.floor(Date.now() / 1000)) + freshness, - proof_hash: Buffer.from(proofHash, "hex"), - proof_uri: proofUri, - }, - caller: signer.publicKey, - }), - ), - [], + async ( + signer: Signer, + { price, freshness, proofHash, proofUri }: Attestation, + ) => { + const cached = queryClient.getQueryState(cycleReadsKey) + const cachedLedgerTime = cached?.data?.ledgerTime + // The cycle read pauses while the tab is hidden, so age the cached value. + const ledgerTime = + cached && cachedLedgerTime != null + ? cachedLedgerTime + + BigInt(Math.floor((Date.now() - cached.dataUpdatedAt) / 1000)) + : await readLedgerTime() + const oracle = await navOracleWriter(signer) + return oracle.attest({ + input: { + nav_per_share: price, + expires_at: + (ledgerTime ?? BigInt(Math.floor(Date.now() / 1000))) + freshness, + proof_hash: Buffer.from(proofHash, "hex"), + proof_uri: proofUri, + }, + caller: signer.publicKey, + }) + }, + [queryClient], ) const toConfirmed = useCallback(() => ({}), []) return useContractTransaction>( From d50ec8dad5b7c9d8c8c765f829aaf43a4caec09e Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 17:48:22 -0300 Subject: [PATCH 2/5] fix(app): read epoch chunks one at a time Closes #417 --- app-lib/readContract.test.ts | 34 ++++++++++++++++++++++++++++++++++ app-lib/readContract.ts | 25 +++++++------------------ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/app-lib/readContract.test.ts b/app-lib/readContract.test.ts index e6a06ae2..d29c79de 100644 --- a/app-lib/readContract.test.ts +++ b/app-lib/readContract.test.ts @@ -183,6 +183,18 @@ function entryFor( } } +const trackInFlight = () => { + const tracker = { inFlight: 0, maxInFlight: 0 } + getLedgerEntriesMock.mockImplementation(async () => { + tracker.inFlight += 1 + tracker.maxInFlight = Math.max(tracker.maxInFlight, tracker.inFlight) + await new Promise((resolve) => setTimeout(resolve, 0)) + tracker.inFlight -= 1 + return { entries: [], latestLedger: 100 } + }) + return tracker +} + describe("readEpochRequests", () => { beforeEach(() => { getLedgerEntriesMock.mockReset() @@ -297,6 +309,17 @@ describe("readEpochRequests", () => { expect(result.get(5n)?.epoch).toEqual({ kind: "unreadable" }) }) + it("reads three chunks one at a time and still returns every epoch", async () => { + const tracker = trackInFlight() + const epochIds = Array.from({ length: 150 }, (_, i) => BigInt(i + 1)) + + const result = await readEpochRequests(CONTRACT_ID, CONTROLLER, epochIds) + + expect(getLedgerEntriesMock).toHaveBeenCalledTimes(3) + expect(tracker.maxInFlight).toBe(1) + expect([...result.keys()]).toEqual(epochIds) + }) + it("marks only the failed chunk's keys unreadable, leaving the other chunk unaffected", async () => { getLedgerEntriesMock .mockRejectedValueOnce(new Error("network down")) @@ -388,6 +411,17 @@ describe("readEpochs", () => { expect(getLedgerEntriesMock.mock.calls[1]).toHaveLength(1) }) + it("reads three chunks one at a time and still returns every epoch", async () => { + const tracker = trackInFlight() + const epochIds = Array.from({ length: 450 }, (_, i) => BigInt(i + 1)) + + const result = await readEpochs(CONTRACT_ID, epochIds) + + expect(getLedgerEntriesMock).toHaveBeenCalledTimes(3) + expect(tracker.maxInFlight).toBe(1) + expect([...result.keys()]).toEqual(epochIds) + }) + it("marks only the failed chunk's epochs unreadable", async () => { getLedgerEntriesMock .mockRejectedValueOnce(new Error("network down")) diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index 88a9d188..9ae47d9d 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -187,16 +187,11 @@ export async function readEpochRequests( EPOCHS_PER_LEDGER_ENTRIES_REQUEST, ) - const resolvedChunks = await Promise.all( - chunks.map(async (keySetChunk) => ({ - keySetChunk, - chunkResult: await fetchChunk( - keySetChunk.flatMap((set) => [set.epoch, set.deposit, set.redeem]), - ), - })), - ) - - for (const { keySetChunk, chunkResult } of resolvedChunks) { + // One chunk at a time keeps a long history under public RPC rate limits. + for (const keySetChunk of chunks) { + const chunkResult = await fetchChunk( + keySetChunk.flatMap((set) => [set.epoch, set.deposit, set.redeem]), + ) for (const set of keySetChunk) { results.set(set.epochId, { epoch: decodeLedgerEntry( @@ -234,14 +229,8 @@ export async function readEpochs( key: contractDataLedgerKey(contract, epochDataKey(epochId)), })) - const resolvedChunks = await Promise.all( - chunk(keyed, LEDGER_ENTRIES_PER_REQUEST).map(async (keyedChunk) => ({ - keyedChunk, - chunkResult: await fetchChunk(keyedChunk.map(({ key }) => key)), - })), - ) - - for (const { keyedChunk, chunkResult } of resolvedChunks) { + for (const keyedChunk of chunk(keyed, LEDGER_ENTRIES_PER_REQUEST)) { + const chunkResult = await fetchChunk(keyedChunk.map(({ key }) => key)) for (const { epochId, key } of keyedChunk) { results.set( epochId, From 2dc1b8c7c57034aa2845995122a9cab08964382c Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Mon, 2 Nov 2026 17:48:22 -0300 Subject: [PATCH 3/5] fix(app): fail the build on missing network config Closes #418 --- app-lib/env.ts | 27 ++-------------- app-lib/networkEnv.test.ts | 28 ++++++++++++++++ app-lib/networkEnv.ts | 38 ++++++++++++++++++++++ app/.env.example | 2 +- app/vite.config.ts | 66 ++++++++++++++++++++------------------ 5 files changed, 104 insertions(+), 57 deletions(-) create mode 100644 app-lib/networkEnv.test.ts create mode 100644 app-lib/networkEnv.ts diff --git a/app-lib/env.ts b/app-lib/env.ts index 5f89accd..7b92c653 100644 --- a/app-lib/env.ts +++ b/app-lib/env.ts @@ -1,5 +1,4 @@ -import { Networks } from "@creit.tech/stellar-wallets-kit" -import { z } from "zod" +import { parseNetworkEnv } from "./networkEnv" type NetworkType = "mainnet" | "testnet" | "futurenet" | "local" @@ -11,29 +10,7 @@ interface Network { 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(), -}) - -const parsed = envSchema.safeParse(import.meta.env) - -const env: z.infer = 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", - } +const env = parseNetworkEnv(import.meta.env) export const stellarNetwork = env.PUBLIC_STELLAR_NETWORK === "STANDALONE" diff --git a/app-lib/networkEnv.test.ts b/app-lib/networkEnv.test.ts new file mode 100644 index 00000000..9ca53c35 --- /dev/null +++ b/app-lib/networkEnv.test.ts @@ -0,0 +1,28 @@ +import { Networks } from "@stellar/stellar-sdk" +import { describe, expect, it } from "vitest" +import { parseNetworkEnv } from "./networkEnv" + +const local = { + 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", +} + +describe("parseNetworkEnv", () => { + it("falls back to localhost when no network is configured", () => { + expect(parseNetworkEnv({})).toEqual(local) + }) + + it("refuses a non-local network naming each missing or invalid variable", () => { + expect(() => + parseNetworkEnv({ + PUBLIC_STELLAR_NETWORK: "TESTNET", + PUBLIC_STELLAR_NETWORK_PASSPHRASE: "not a passphrase", + PUBLIC_STELLAR_RPC_URL: "https://soroban-testnet.stellar.org", + }), + ).toThrow( + "Invalid Stellar network configuration: PUBLIC_STELLAR_NETWORK_PASSPHRASE, PUBLIC_STELLAR_HORIZON_URL", + ) + }) +}) diff --git a/app-lib/networkEnv.ts b/app-lib/networkEnv.ts new file mode 100644 index 00000000..d32f55eb --- /dev/null +++ b/app-lib/networkEnv.ts @@ -0,0 +1,38 @@ +import { Networks } from "@stellar/stellar-sdk" +import { z } from "zod" + +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().min(1), + PUBLIC_STELLAR_HORIZON_URL: z.string().min(1), +}) + +type NetworkEnv = z.infer + +const localEnv: NetworkEnv = { + 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", +} + +const localNetworks = [undefined, "LOCAL", "STANDALONE"] + +export function parseNetworkEnv( + env: Record, +): NetworkEnv { + const parsed = envSchema.safeParse(env) + if (parsed.success) return parsed.data + if (localNetworks.includes(env.PUBLIC_STELLAR_NETWORK)) return localEnv + const invalid = parsed.error.issues.map(({ path }) => String(path[0])) + throw new Error( + `Invalid Stellar network configuration: ${invalid.join(", ")}`, + ) +} diff --git a/app/.env.example b/app/.env.example index d6b709c6..8dd62517 100644 --- a/app/.env.example +++ b/app/.env.example @@ -22,7 +22,7 @@ PUBLIC_STELLAR_HORIZON_URL="http://localhost:8000" # PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org" # PUBLIC_STELLAR_HORIZON_URL="https://horizon-testnet.stellar.org" -# PUBLIC_STELLAR_NETWORK="MAINNET" +# PUBLIC_STELLAR_NETWORK="PUBLIC" # PUBLIC_STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" # PUBLIC_STELLAR_RPC_URL= # PUBLIC_STELLAR_HORIZON_URL= diff --git a/app/vite.config.ts b/app/vite.config.ts index 82527144..4d0a45ec 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,39 +1,43 @@ import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" +import { defineConfig, loadEnv } from "vite" import { nodePolyfills } from "vite-plugin-node-polyfills" import wasm from "vite-plugin-wasm" +import { parseNetworkEnv } from "../app-lib/networkEnv" // https://vite.dev/config/ -export default defineConfig({ - plugins: [ - react(), - nodePolyfills({ - include: ["buffer"], - globals: { - Buffer: true, - }, - }), - wasm(), - ], - resolve: { - dedupe: ["@stellar/stellar-sdk"], - }, - build: { - target: "esnext", - }, - optimizeDeps: { - exclude: ["@stellar/stellar-xdr-json"], - }, - define: { - global: "window", - }, - envPrefix: "PUBLIC_", - server: { - proxy: { - "/friendbot": { - target: "http://localhost:8000/friendbot", - changeOrigin: true, +export default defineConfig(({ mode }) => { + parseNetworkEnv(loadEnv(mode, import.meta.dirname, "PUBLIC_")) + return { + plugins: [ + react(), + nodePolyfills({ + include: ["buffer"], + globals: { + Buffer: true, + }, + }), + wasm(), + ], + resolve: { + dedupe: ["@stellar/stellar-sdk"], + }, + build: { + target: "esnext", + }, + optimizeDeps: { + exclude: ["@stellar/stellar-xdr-json"], + }, + define: { + global: "window", + }, + envPrefix: "PUBLIC_", + server: { + proxy: { + "/friendbot": { + target: "http://localhost:8000/friendbot", + changeOrigin: true, + }, }, }, - }, + } }) From 51cf66895d173c303a0d5cd47eb50adf8948b1f4 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 3 Nov 2026 09:42:37 -0300 Subject: [PATCH 4/5] fix(app): accept a lowercase local network --- app-lib/networkEnv.test.ts | 4 ++++ app-lib/networkEnv.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app-lib/networkEnv.test.ts b/app-lib/networkEnv.test.ts index 9ca53c35..93c92931 100644 --- a/app-lib/networkEnv.test.ts +++ b/app-lib/networkEnv.test.ts @@ -14,6 +14,10 @@ describe("parseNetworkEnv", () => { expect(parseNetworkEnv({})).toEqual(local) }) + it("falls back to localhost when the local network is named in lowercase", () => { + expect(parseNetworkEnv({ PUBLIC_STELLAR_NETWORK: "local" })).toEqual(local) + }) + it("refuses a non-local network naming each missing or invalid variable", () => { expect(() => parseNetworkEnv({ diff --git a/app-lib/networkEnv.ts b/app-lib/networkEnv.ts index d32f55eb..dbe1f5e3 100644 --- a/app-lib/networkEnv.ts +++ b/app-lib/networkEnv.ts @@ -30,7 +30,8 @@ export function parseNetworkEnv( ): NetworkEnv { const parsed = envSchema.safeParse(env) if (parsed.success) return parsed.data - if (localNetworks.includes(env.PUBLIC_STELLAR_NETWORK)) return localEnv + if (localNetworks.includes(env.PUBLIC_STELLAR_NETWORK?.toUpperCase())) + return localEnv const invalid = parsed.error.issues.map(({ path }) => String(path[0])) throw new Error( `Invalid Stellar network configuration: ${invalid.join(", ")}`, From eea0a0b5423b410ba0ddf4dd0112b65d6a856d58 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 3 Nov 2026 09:42:38 -0300 Subject: [PATCH 5/5] fix(app): ignore clock skew when aging ledger time --- app/src/hooks/useAttest.test.ts | 20 ++++++++++++++++++++ app/src/hooks/useAttest.ts | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 2a803e6c..1a318373 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -135,6 +135,26 @@ describe("useAttest", () => { }) }) + it("does not age a cached ledger time backwards when the clock moved back since it was read", async () => { + const ledgerTime = BigInt(wallClock) + 3_600n + oracleMock.attest.mockResolvedValue(successful()) + const { result, queryClient } = renderAttest() + queryClient.setQueryData( + cycleReadsKey, + { ledgerTime }, + { updatedAt: Date.now() + 600_000 }, + ) + + await act(() => result.current.submit(attestation)) + + expect(oracleMock.attest).toHaveBeenCalledWith({ + input: expect.objectContaining({ + expires_at: ledgerTime + attestation.freshness, + }), + caller: investorAddress, + }) + }) + it("reads ledger time once when none is cached", async () => { const ledgerTime = BigInt(wallClock) + 3_600n readLedgerTimeMock.mockResolvedValue(ledgerTime) diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts index 3803e5bf..1f32dd3d 100644 --- a/app/src/hooks/useAttest.ts +++ b/app/src/hooks/useAttest.ts @@ -46,7 +46,9 @@ export function useAttest(): UseAttest { const ledgerTime = cached && cachedLedgerTime != null ? cachedLedgerTime + - BigInt(Math.floor((Date.now() - cached.dataUpdatedAt) / 1000)) + BigInt( + Math.floor(Math.max(0, Date.now() - cached.dataUpdatedAt) / 1000), + ) : await readLedgerTime() const oracle = await navOracleWriter(signer) return oracle.attest({