diff --git a/.env.example b/.env.example index 7f66270..e76fd9a 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 97f4764..e86177b 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 324c448..1386294 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 the vault rejection message/code and allows retry without reconciliation", async () => { const { WalletServiceError } = await import("./services/walletService"); const error = new WalletServiceError("CONTRACT_REJECTED", "This wallet is not on the vault's deposit allowlist."); diff --git a/src/App.tsx b/src/App.tsx index 7f16e67..efead44 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"; @@ -470,6 +470,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 [depositContractErrorCode, setDepositContractErrorCode] = useState(null); const [depositFailureCode, setDepositFailureCode] = useState(null); const [depositRequiresReconciliation, setDepositRequiresReconciliation] = useState(false); @@ -586,6 +587,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(() => { @@ -595,8 +597,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"); @@ -605,6 +608,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); }) @@ -1479,6 +1499,11 @@ function App() {
{checkDepositStatusMessage}
+ {depositStage === "input" && (vaultBlockReason || vaultPreflight.status === "unverified") && ( +

+ {vaultBlockReason ?? vaultPreflight.message} +

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

Install Freighter to sign this Stellar deposit.

@@ -1518,8 +1543,10 @@ function App() { + ) : vaultPreflight.status === "checking" && depositStage === "input" ? ( + ) : ( - )} diff --git a/src/config/constants.ts b/src/config/constants.ts index a9e333a..87251ce 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 0000000..a1dbcf5 --- /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 0000000..192c91c --- /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 5518963..e92fa31 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 a57441e..a0dd305 100644 --- a/src/services/walletService.test.ts +++ b/src/services/walletService.test.ts @@ -50,7 +50,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 { @@ -387,6 +387,38 @@ describe("walletService", () => { expect(mocks.signTransaction).not.toHaveBeenCalled(); }); + 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 99e7a30..2a965f1 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -24,12 +24,14 @@ import { STELLAR_USDC_DECIMALS, STELLAR_USDC_ASSET, STELLAR_USDC_CONFIGURATION_ERROR, + STELLAR_VAULT_CONFIG, STELLAR_VAULT_CONTRACT_ID, type StellarNetwork, } from "../config/constants"; import { decodeVaultError } from "./vaultErrors"; import { logError } from "./SecureErrorHandler"; import { walletStore } from "../state/walletStore"; +import { readVaultDepositPreflight, type VaultDepositPreflight } from "./vaultReads"; const STELLAR_NETWORK_LABELS: Record = { mainnet: "Mainnet", @@ -277,11 +279,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) { @@ -347,6 +346,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.