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
27 changes: 2 additions & 25 deletions app-lib/env.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<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",
}
const env = parseNetworkEnv(import.meta.env)

export const stellarNetwork =
env.PUBLIC_STELLAR_NETWORK === "STANDALONE"
Expand Down
32 changes: 32 additions & 0 deletions app-lib/networkEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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("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({
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",
)
})
})
39 changes: 39 additions & 0 deletions app-lib/networkEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zod 4 accepts enum-like objects in z.enum; z.nativeEnum is deprecated there. Same line as env.ts on feedback.

PUBLIC_STELLAR_RPC_URL: z.string().min(1),
PUBLIC_STELLAR_HORIZON_URL: z.string().min(1),
})

type NetworkEnv = z.infer<typeof envSchema>

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<string, string | undefined>,
): NetworkEnv {
const parsed = envSchema.safeParse(env)
if (parsed.success) return parsed.data
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(", ")}`,
)
}
34 changes: 34 additions & 0 deletions app-lib/readContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"))
Expand Down
25 changes: 7 additions & 18 deletions app-lib/readContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EpochInfo>(
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
92 changes: 89 additions & 3 deletions app/src/hooks/useAttest.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AppLib>()),
readLedgerTime: readLedgerTimeMock,
}))

vi.mock("../config/clients", () => ({
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -86,6 +97,81 @@ 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("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)
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()
Expand Down
Loading
Loading