From 3eeba1b6f8200b5774a127ca034f39b708ea44f0 Mon Sep 17 00:00:00 2001 From: Adejoke46 <332049580+Adejoke46@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:27:56 -0700 Subject: [PATCH 1/2] fix(catalog): restore baseline type and identifier consistency --- src/api/catalogApi.ts | 16 ++++++++-------- src/data/mockApis.ts | 8 ++++++++ src/pages/ApiDetailPage.tsx | 6 +++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/api/catalogApi.ts b/src/api/catalogApi.ts index 2cebc49a..8e49835f 100644 --- a/src/api/catalogApi.ts +++ b/src/api/catalogApi.ts @@ -138,7 +138,7 @@ export interface BackendApiRow { /** * Adapts a snake_case, cursor-paginated Callora-Backend API row into the - * frontend `APIIdem` contract. + * frontend `APIItem` contract. * * Rules: * - Stringifies `id`. @@ -221,7 +221,7 @@ export function adaptBackendApi(row: unknown): APIItem | null { * `name`) are dropped instead of poisoning the list, so one malformed row does * not take the whole page down. * - * Accepts both legacy `APIIdem` shapes and backend rows via `adaptBackendApi`. + * Accepts both legacy `APIItem` shapes and backend rows via `adaptBackendApi`. */ export function normalizeCatalog(payload: unknown): APIItem[] { const list = Array.isArray(payload) @@ -282,8 +282,8 @@ export function createCatalogApi( ): CatalogApi { const fetchImpl = options.fetchImpl ?? globalThis.fetch; - let cachedEtg: string | null = null; - let cachedList: APIIdem[] = []; + let cachedEtag: string | null = null; + let cachedList: APIItem[] = []; const resolveBase = (): string => options.baseUrl !== undefined @@ -299,8 +299,8 @@ export function createCatalogApi( const headers: Record = { Accept: "application/json", }; - if (cachedEtg) { - headers["If-None-Match"] = cachedEtg; + if (cachedEtag) { + headers["If-None-Match"] = cachedEtag; } const base = resolveBase(); @@ -356,7 +356,7 @@ export function createCatalogApi( throw abortError(); } - const items = normalizeCatalogue(payload); + const items = normalizeCatalog(payload); cachedList = items; return items; }, @@ -448,7 +448,7 @@ export async function fetchCatalog(signal?: AbortSignal): Promise { // When API_BASE is configured, errors must reject so the UI surfaces its // error or stale-data states rather than hiding failures behind mock data. - return await catalogAPI["fetchCatalog"](signal); + return await catalogApi["fetchCatalog"](signal); } /** diff --git a/src/data/mockApis.ts b/src/data/mockApis.ts index 4e86a6fb..bb870778 100644 --- a/src/data/mockApis.ts +++ b/src/data/mockApis.ts @@ -36,6 +36,14 @@ export type APIItem = { sparklineValues?: number[]; pricingOnRequest?: boolean; baseUrl?: string; + sla?: { + uptimeTargetPct?: number; + p99LatencyMs?: number; + incidentResponseMinutes?: number; + maintenanceWindow?: string; + supportTier?: string; + creditThresholdPct?: number; + }; }; diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index 64679a30..3b87a631 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -25,12 +25,12 @@ import StatusBadge, { apiStatusToVariant } from "../components/StatusBadge"; import { useToast } from "../components/Toast"; import { useCollections } from "../state/collectionsStore"; import RelatedApisRail from "../components/RelatedApisRail"; -import MOCK_APIS from "../data/mockApis"; +import MOCK_APIS, { type APIItem as ApiDetail } from "../data/mockApis"; import KbdHint from "../components/KbdHint"; import { SHORTCUTS } from "../hooks/useGlobalShortcuts"; import LiveRegion from "../components/LiveRegion"; import { toCurl, type CurlRequest } from "../utils/toCurl"; -import { fetchApiDetail, type ApiDetail } from "../api/catalogApi"; +import { fetchApiDetail } from "../api/catalogApi"; /** * ApiDetailPage @@ -698,7 +698,7 @@ export default function ApiDetailPage({ onBack }: Props) { fetchApiDetail(id, controller.signal) .then((result) => { if (!active) return; - if (result.kind === "not-found") { + if (result.status === "not-found") { setApi(null); setStatus("not-found"); return; From aefbb8c74ea541903b1bad58096f6225155d8495 Mon Sep 17 00:00:00 2001 From: Adejoke46 <332049580+Adejoke46@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:01:12 -0700 Subject: [PATCH 2/2] feat(deposit): default to testnet vault and preflight pause/allowlist Testnet builds now default to the reference vault when the env var is empty. The config is validated with StrKey before signing, so an account address or missing value blocks Approve with a specific reason. Before the wallet is asked to sign, a read-only simulation checks is_paused and is_authorized_depositor. A paused vault or an unlisted wallet disables Approve with the reason linked via aria-describedby. If the preflight cannot run, Approve stays enabled with a warning. Closes #1312 Co-Authored-By: Claude Haiku 5.5 --- .env.example | 5 +-- docs/Configuration.md | 2 +- src/App.deposit.test.tsx | 37 ++++++++++++++++++++++ src/App.tsx | 33 ++++++++++++++++++-- src/config/constants.ts | 22 +++++++++++--- src/services/vaultConfig.test.ts | 31 +++++++++++++++++++ src/services/vaultConfig.ts | 49 ++++++++++++++++++++++++++++++ src/services/vaultReads.ts | 42 +++++++++++++++++++++++++ src/services/walletService.test.ts | 35 ++++++++++++++++++++- src/services/walletService.ts | 20 +++++++++--- 10 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 src/services/vaultConfig.test.ts create mode 100644 src/services/vaultConfig.ts diff --git a/.env.example b/.env.example index 7f662707..e76fd9a0 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,9 @@ VITE_STELLAR_NETWORK=testnet # (or futurenet) RPC; mainnet has no default and must be set explicitly. VITE_STELLAR_RPC_URL= -# Deployed Callora vault contract ID that receives USDC deposits. Deposits -# cannot be submitted until this is set. Must match VITE_STELLAR_NETWORK. +# Deployed Callora vault contract ID that receives USDC deposits. Testnet +# builds default to the reference testnet vault when this is empty. Mainnet and +# futurenet deposits are blocked until this is set. Must match VITE_STELLAR_NETWORK. VITE_STELLAR_VAULT_CONTRACT_ID= # Classic USDC issuer backing the vault's Stellar Asset Contract. Testnet diff --git a/docs/Configuration.md b/docs/Configuration.md index a2cd7869..f1d1afeb 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -157,7 +157,7 @@ Key contract details and adapter behavior: | `resolveStellarNetwork(raw)` | validate with dev-only warning | | `STELLAR_NETWORK_PASSPHRASE` | passphrase for `STELLAR_NETWORK`, used to sign deposits | | `STELLAR_RPC_URL` | `VITE_STELLAR_RPC_URL`, else the network's public RPC (none for mainnet) | -| `STELLAR_VAULT_CONTRACT_ID` | `VITE_STELLAR_VAULT_CONTRACT_ID`, empty by default | +| `STELLAR_VAULT_CONTRACT_ID` | `VITE_STELLAR_VAULT_CONTRACT_ID`; defaults to the reference testnet vault on `testnet`, empty on other networks. An invalid value (for example an account address) blocks deposits before signing. | | `STELLAR_USDC_ASSET` | classic USDC code and validated configured issuer | | `STELLAR_USDC_CONFIGURATION_ERROR` | invalid/missing issuer configuration error, otherwise `null` | | `STELLAR_HORIZON_URL` | Horizon origin for `STELLAR_NETWORK` | diff --git a/src/App.deposit.test.tsx b/src/App.deposit.test.tsx index 43548df3..886efdc2 100644 --- a/src/App.deposit.test.tsx +++ b/src/App.deposit.test.tsx @@ -32,6 +32,7 @@ const walletMocks = vi.hoisted(() => ({ submitVaultDeposit: vi.fn(), checkDepositStatus: vi.fn(), getWalletAddress: vi.fn(), + preflightVaultDeposit: vi.fn(), fetch: vi.fn(), signTransaction: vi.fn(), network: "testnet", @@ -97,6 +98,7 @@ vi.mock("./services/walletService", async (importOriginal) => { submitVaultDeposit: walletMocks.submitVaultDeposit, checkDepositStatus: walletMocks.checkDepositStatus, getWalletAddress: walletMocks.getWalletAddress, + preflightVaultDeposit: walletMocks.preflightVaultDeposit, }; }); @@ -120,6 +122,7 @@ beforeEach(() => { }) }); walletMocks.isWalletAvailable.mockReset().mockResolvedValue(true); walletMocks.submitVaultDeposit.mockReset(); + walletMocks.preflightVaultDeposit.mockReset().mockResolvedValue({ paused: false, allowlisted: true }); walletMocks.checkDepositStatus.mockReset(); sessionStorage.clear(); }); @@ -340,6 +343,12 @@ async function renderModalReady() { return { dialog, amountInput }; } +async function flushMicrotasks() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + function setAmount(input: HTMLInputElement, value: string) { fireEvent.change(input, { target: { value } }); } @@ -425,6 +434,34 @@ describe("App deposit modal timed transitions", () => { expect(screen.getAllByText(/334\.62/).length).toBeGreaterThan(0); }); + it("blocks Approve with the paused reason before any signature prompt", async () => { + walletMocks.preflightVaultDeposit.mockResolvedValueOnce({ paused: true, allowlisted: true }); + const { amountInput } = await renderModalReady(); + setAmount(amountInput, "80"); + await flushMicrotasks(); + expect(screen.getByText("Deposits are paused on this vault right now.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Approve deposit transaction" }) as HTMLButtonElement).disabled).toBe(true); + expect(walletMocks.submitVaultDeposit).not.toHaveBeenCalled(); + }); + + it("blocks Approve when the wallet is not on the deposit allowlist", async () => { + walletMocks.preflightVaultDeposit.mockResolvedValueOnce({ paused: false, allowlisted: false }); + const { amountInput } = await renderModalReady(); + setAmount(amountInput, "80"); + await flushMicrotasks(); + expect(screen.getByText("This wallet is not on the vault's deposit allowlist.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Approve deposit transaction" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("keeps Approve enabled with a warning when the vault preflight cannot run", async () => { + walletMocks.preflightVaultDeposit.mockRejectedValueOnce(new Error("rpc down")); + const { amountInput } = await renderModalReady(); + setAmount(amountInput, "80"); + await flushMicrotasks(); + expect(screen.getByText("Could not verify vault status. You can still try the deposit.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Approve deposit transaction" }) as HTMLButtonElement).disabled).toBe(false); + }); + it("shows failure and retry restores the submitted amount", async () => { const { WalletServiceError } = await import("./services/walletService"); walletMocks.submitVaultDeposit.mockRejectedValue( diff --git a/src/App.tsx b/src/App.tsx index d7bf0f21..1da05d85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import NotFound from "./components/NotFound"; import { startRouteLoading, stopRouteLoading } from "./hooks/useRouteLoading"; import { formatUsdc, formatUsdShortcut, formatUsdcStroops, normalizeUsdcAmountInput, stroopsToUsdcAmount, usdcAmountToStroops, USDC_DECIMALS } from "./utils/format"; import { useNetworkFee } from "./components/DepositPreview"; -import { ENABLE_DEMO_OUTCOME, EXPLORER_BASE_URL, MIN_DEPOSIT, NETWORK_FEE, PRESET_AMOUNTS, EXTERNAL_LINKS, TESTNET_DEPLOYMENT, testnetContractUrl, STELLAR_NETWORK, STELLAR_USDC_CONFIGURATION_ERROR, STELLAR_VAULT_CONTRACT_ID, PENDING_DEPOSIT_STORAGE_KEY, TRANSACTION_TIMEOUT_SECONDS, type StellarNetwork } from "./config/constants"; +import { ENABLE_DEMO_OUTCOME, EXPLORER_BASE_URL, MIN_DEPOSIT, NETWORK_FEE, PRESET_AMOUNTS, EXTERNAL_LINKS, TESTNET_DEPLOYMENT, testnetContractUrl, STELLAR_NETWORK, STELLAR_USDC_CONFIGURATION_ERROR, STELLAR_VAULT_CONFIG, STELLAR_VAULT_CONTRACT_ID, PENDING_DEPOSIT_STORAGE_KEY, TRANSACTION_TIMEOUT_SECONDS, type StellarNetwork } from "./config/constants"; import type { WalletServiceErrorCode, UsdcTrustlineStatus } from "./services/walletService"; import { useVaultBalances } from "./hooks/useVaultBalances"; import BalanceReadout from "./components/BalanceReadout"; @@ -458,6 +458,7 @@ function App() { const [walletAddress, setWalletAddress] = useState(""); const [isAddingTrustline, setIsAddingTrustline] = useState(false); const [trustlineCheckVersion, setTrustlineCheckVersion] = useState(0); + const [vaultPreflight, setVaultPreflight] = useState<{ status: "idle" | "checking" | "ready" | "blocked" | "unverified"; message: string }>({ status: "idle", message: "" }); const [depositFailureCode, setDepositFailureCode] = useState(null); const [depositRequiresReconciliation, setDepositRequiresReconciliation] = useState(false); const [txHash, setTxHash] = useState(""); @@ -573,6 +574,7 @@ function App() { const hasValidAmount = validationMessage.length === 0; const stageLabel = getStageLabel(depositStage, hasValidAmount); + const vaultBlockReason = STELLAR_VAULT_CONFIG.error ?? (vaultPreflight.status === "blocked" ? vaultPreflight.message : null); const pendingHashLabel = txHash ? `${txHash.slice(0, 10)}...${txHash.slice(-8)}` : null; useEffect(() => { @@ -582,8 +584,9 @@ function App() { setWalletAvailability("checking"); setTrustlineStatus("checking"); setTrustlineMessage(""); + setVaultPreflight({ status: "idle", message: "" }); import("./services/walletService") - .then(async ({ isWalletAvailable, getWalletAddress, hasUsdcTrustline }) => { + .then(async ({ isWalletAvailable, getWalletAddress, hasUsdcTrustline, preflightVaultDeposit }) => { const available = await isWalletAvailable(); if (!isCurrent) return; setWalletAvailability(available ? "available" : "missing"); @@ -592,6 +595,23 @@ function App() { const address = await getWalletAddress(); if (!isCurrent) return; setWalletAddress(address); + if (!STELLAR_VAULT_CONFIG.error) { + setVaultPreflight({ status: "checking", message: "" }); + preflightVaultDeposit(address) + .then((result) => { + if (!isCurrent) return; + if (result.paused) { + setVaultPreflight({ status: "blocked", message: "Deposits are paused on this vault right now." }); + } else if (!result.allowlisted) { + setVaultPreflight({ status: "blocked", message: "This wallet is not on the vault's deposit allowlist." }); + } else { + setVaultPreflight({ status: "ready", message: "" }); + } + }) + .catch(() => { + if (isCurrent) setVaultPreflight({ status: "unverified", message: "Could not verify vault status. You can still try the deposit." }); + }); + } const status = await hasUsdcTrustline(address); if (isCurrent) setTrustlineStatus(status); }) @@ -1460,6 +1480,11 @@ function App() {
{checkDepositStatusMessage}
+ {depositStage === "input" && (vaultBlockReason || vaultPreflight.status === "unverified") && ( +

+ {vaultBlockReason ?? vaultPreflight.message} +

+ )} {walletAvailability === "missing" ? (

Install Freighter to sign this Stellar deposit.

@@ -1499,8 +1524,10 @@ function App() { + ) : vaultPreflight.status === "checking" && depositStage === "input" ? ( + ) : ( - )} diff --git a/src/config/constants.ts b/src/config/constants.ts index a9e333aa..87251ce6 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,4 +1,5 @@ import { StrKey } from "@stellar/stellar-sdk"; +import { resolveVaultConfig } from "../services/vaultConfig"; /** * Shared application constants. @@ -185,10 +186,21 @@ export const STELLAR_RPC_URL = normalizeBaseUrl(runtimeEnv.VITE_STELLAR_RPC_URL as string | undefined) || DEFAULT_STELLAR_RPC_URLS[STELLAR_NETWORK]; -/** Deployed Callora vault contract that receives deposits. */ -export const STELLAR_VAULT_CONTRACT_ID = String( - runtimeEnv.VITE_STELLAR_VAULT_CONTRACT_ID ?? "", -).trim(); +/** Reference Callora vault deployed on Stellar testnet. */ +const TESTNET_VAULT_CONTRACT_ID = + "CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ"; + +/** + * Deployed Callora vault contract that receives deposits. `testnet` defaults to + * the reference vault; mainnet and futurenet must set `VITE_STELLAR_VAULT_CONTRACT_ID`. + */ +export const STELLAR_VAULT_CONFIG = resolveVaultConfig({ + network: STELLAR_NETWORK, + envContractId: runtimeEnv.VITE_STELLAR_VAULT_CONTRACT_ID as string | undefined, + testnetDefault: TESTNET_VAULT_CONTRACT_ID, +}); + +export const STELLAR_VAULT_CONTRACT_ID = STELLAR_VAULT_CONFIG.contractId; export const STELLAR_USDC_DECIMALS = 7; @@ -224,7 +236,7 @@ export const STELLAR_HORIZON_URL = { */ export const TESTNET_DEPLOYMENT = { network: "testnet", - vault: "CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ", + vault: TESTNET_VAULT_CONTRACT_ID, settlement: "CB4BUGCX5LFS5KYWAO2J4ZQTALOQSGXFFAOVKKGAS32OFZLU7O7YYGHS", revenuePool: "CC5KINWRSLKSSQG4ZOTP3EZIOUVC6ER7HL5GZALYY4LVE6ZIMX4PMCSE", } as const; diff --git a/src/services/vaultConfig.test.ts b/src/services/vaultConfig.test.ts new file mode 100644 index 00000000..a1dbcf5b --- /dev/null +++ b/src/services/vaultConfig.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { resolveVaultConfig } from "./vaultConfig"; + +const TESTNET_VAULT = "CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ"; +const OTHER_VAULT = "CB4BUGCX5LFS5KYWAO2J4ZQTALOQSGXFFAOVKKGAS32OFZLU7O7YYGHS"; +const ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +describe("resolveVaultConfig", () => { + it("defaults testnet to the reference vault when the env var is empty", () => { + expect(resolveVaultConfig({ network: "testnet", envContractId: "", testnetDefault: TESTNET_VAULT })) + .toEqual({ contractId: TESTNET_VAULT, source: "testnet-default" }); + }); + + it("reports a missing vault on mainnet when the env var is empty", () => { + const config = resolveVaultConfig({ network: "mainnet", envContractId: "", testnetDefault: TESTNET_VAULT }); + expect(config.contractId).toBe(""); + expect(config.source).toBe("missing"); + expect(config.error).toMatch(/not configured/); + }); + + it("prefers a valid env value over the testnet default", () => { + expect(resolveVaultConfig({ network: "testnet", envContractId: ` ${OTHER_VAULT} `, testnetDefault: TESTNET_VAULT })) + .toEqual({ contractId: OTHER_VAULT, source: "env" }); + }); + + it("rejects an account address used in place of a contract ID", () => { + const config = resolveVaultConfig({ network: "testnet", envContractId: ACCOUNT, testnetDefault: TESTNET_VAULT }); + expect(config.contractId).toBe(""); + expect(config.error).toMatch(/not a valid contract address/); + }); +}); diff --git a/src/services/vaultConfig.ts b/src/services/vaultConfig.ts new file mode 100644 index 00000000..192c91cb --- /dev/null +++ b/src/services/vaultConfig.ts @@ -0,0 +1,49 @@ +import { StrKey } from "@stellar/stellar-sdk"; + +export type VaultConfigSource = "env" | "testnet-default" | "missing"; + +export type VaultConfig = { + /** Contract ID deposits are sent to; empty when the config is unusable. */ + contractId: string; + source: VaultConfigSource; + /** User-facing reason deposits are blocked; absent when the config is usable. */ + error?: string; +}; + +/** + * Resolve the vault contract ID for a network. `testnet` falls back to the + * reference testnet vault when the env var is empty; other networks have no + * default and must set the variable. Pure, so it can be tested without env. + */ +export function resolveVaultConfig(env: { + network: string; + envContractId?: string; + testnetDefault: string; +}): VaultConfig { + const fromEnv = (env.envContractId ?? "").trim(); + let contractId = fromEnv; + let source: VaultConfigSource = "env"; + + if (!contractId && env.network === "testnet") { + contractId = env.testnetDefault; + source = "testnet-default"; + } + + if (!contractId) { + return { + contractId: "", + source: "missing", + error: "The Callora vault contract is not configured for this network.", + }; + } + + if (!StrKey.isValidContract(contractId)) { + return { + contractId: "", + source, + error: "The configured Callora vault contract ID is not a valid contract address.", + }; + } + + return { contractId, source }; +} diff --git a/src/services/vaultReads.ts b/src/services/vaultReads.ts index 5518963e..e92fa312 100644 --- a/src/services/vaultReads.ts +++ b/src/services/vaultReads.ts @@ -280,3 +280,45 @@ export async function readWalletUsdcBalance(address: string): Promise { return expectStroops(scValToNative(retval), "USDC balance()"); }); } + +export type VaultDepositPreflight = { + paused: boolean; + allowlisted: boolean; +}; + +/** + * Simulate the vault's `is_paused()` and `is_authorized_depositor(address)` + * entry points, so the UI can explain why a deposit would fail before the + * wallet is asked to sign. Read-only: nothing is signed or submitted. + */ +export async function readVaultDepositPreflight( + address: string, +): Promise { + if (!isVaultReadConfigured()) throw notConfiguredError(); + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new VaultReadError( + "BAD_RESPONSE", + "A valid Stellar account address is required to check vault deposits.", + ); + } + return dedupe(`deposit-preflight:${STELLAR_VAULT_CONTRACT_ID}:${address}`, async () => { + const [pausedVal, allowlistedVal] = await Promise.all([ + simulate(STELLAR_VAULT_CONTRACT_ID, "is_paused", [], address), + simulate( + STELLAR_VAULT_CONTRACT_ID, + "is_authorized_depositor", + [new Address(address).toScVal()], + address, + ), + ]); + const paused = scValToNative(pausedVal); + const allowlisted = scValToNative(allowlistedVal); + if (typeof paused !== "boolean" || typeof allowlisted !== "boolean") { + throw new VaultReadError( + "BAD_RESPONSE", + "Vault deposit preflight did not return booleans.", + ); + } + return { paused, allowlisted }; + }); +} diff --git a/src/services/walletService.test.ts b/src/services/walletService.test.ts index f90204b5..7ede989c 100644 --- a/src/services/walletService.test.ts +++ b/src/services/walletService.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ server: { getAccount: vi.fn(), prepareTransaction: vi.fn(), + simulateTransaction: vi.fn(), sendTransaction: vi.fn(), getTransaction: vi.fn(), }, @@ -48,7 +49,7 @@ vi.mock("../config/constants", async (importOriginal) => ({ get STELLAR_USDC_CONFIGURATION_ERROR() { return mocks.configurationError; }, })); -import { Account, Asset, Transaction } from "@stellar/stellar-sdk"; +import { Account, Asset, Transaction, xdr } from "@stellar/stellar-sdk"; import { STELLAR_USDC_ASSET, STELLAR_NETWORK_PASSPHRASE } from "../config/constants"; const walletAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; import { @@ -356,6 +357,38 @@ describe("walletService", () => { await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "TRANSACTION_FAILED", requiresReconciliation: false }); }); + describe("preflightVaultDeposit", () => { + const simulatedBool = (value: boolean) => ({ + _parsed: true, latestLedger: 1, events: [], transactionData: {}, result: { retval: xdr.ScVal.scvBool(value) }, + }); + + it("reports paused and allowlist state from read-only simulations", async () => { + const { preflightVaultDeposit } = await import("./walletService"); + mocks.server.simulateTransaction + .mockResolvedValueOnce(simulatedBool(true)) + .mockResolvedValueOnce(simulatedBool(false)); + await expect(preflightVaultDeposit(walletAddress)).resolves.toEqual({ paused: true, allowlisted: false }); + expect(mocks.server.simulateTransaction).toHaveBeenCalledTimes(2); + }); + + it("never signs or submits while checking", async () => { + const { preflightVaultDeposit } = await import("./walletService"); + mocks.server.simulateTransaction + .mockResolvedValueOnce(simulatedBool(false)) + .mockResolvedValueOnce(simulatedBool(true)); + await preflightVaultDeposit(walletAddress); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + expect(mocks.server.sendTransaction).not.toHaveBeenCalled(); + }); + + it("surfaces RPC failures so the UI can leave Approve enabled", async () => { + const { preflightVaultDeposit } = await import("./walletService"); + mocks.server.simulateTransaction.mockRejectedValueOnce(new Error("offline")); + await expect(preflightVaultDeposit(walletAddress)).rejects.toThrow(); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + }); + }); + describe("checkDepositStatus", () => { it("returns SUCCESS when getTransaction reports SUCCESS", async () => { mocks.server.getTransaction.mockResolvedValueOnce({ status: "SUCCESS" }); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 318d3944..8159587b 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -24,10 +24,12 @@ import { STELLAR_USDC_DECIMALS, STELLAR_USDC_ASSET, STELLAR_USDC_CONFIGURATION_ERROR, + STELLAR_VAULT_CONFIG, STELLAR_VAULT_CONTRACT_ID, type StellarNetwork, } from "../config/constants"; import { walletStore } from "../state/walletStore"; +import { readVaultDepositPreflight, type VaultDepositPreflight } from "./vaultReads"; const STELLAR_NETWORK_LABELS: Record = { mainnet: "Mainnet", @@ -271,11 +273,8 @@ function createRpcServer(): rpc.Server { } function validateConfiguration(): void { - if (!STELLAR_VAULT_CONTRACT_ID) { - throw new WalletServiceError( - "CONFIGURATION", - "The Callora vault contract is not configured for this network.", - ); + if (STELLAR_VAULT_CONFIG.error) { + throw new WalletServiceError("CONFIGURATION", STELLAR_VAULT_CONFIG.error); } if (!STELLAR_RPC_URL) { @@ -341,6 +340,17 @@ async function waitForConfirmation( ); } +/** + * Read-only check of whether the vault would accept a deposit from `address`. + * Simulates only: nothing is signed or submitted, so Freighter is not prompted. + */ +export async function preflightVaultDeposit( + address: string, +): Promise { + validateConfiguration(); + return readVaultDepositPreflight(address); +} + /** * Build, sign, submit, and confirm a Callora Soroban vault deposit. * `onSubmitted` receives only the hash returned by Stellar RPC after submission.