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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
37 changes: 37 additions & 0 deletions src/App.deposit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -97,6 +98,7 @@ vi.mock("./services/walletService", async (importOriginal) => {
submitVaultDeposit: walletMocks.submitVaultDeposit,
checkDepositStatus: walletMocks.checkDepositStatus,
getWalletAddress: walletMocks.getWalletAddress,
preflightVaultDeposit: walletMocks.preflightVaultDeposit,
};
});

Expand All @@ -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();
});
Expand Down Expand Up @@ -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 } });
}
Expand Down Expand Up @@ -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.");
Expand Down
33 changes: 30 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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";
Expand Down Expand Up @@ -234,11 +234,11 @@

/** `/rate-limit` no longer exists; limits are per-API. Send users to the Marketplace. */
function LegacyRateLimitRedirect() {
const { showToast } = useToast();

Check failure on line 237 in src/App.tsx

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'useToast'.
useEffect(() => {
showToast("Rate limits are now shown per API. Pick an API to see its limits.");
}, [showToast]);
return <Navigate to={APP_ROUTES.marketplace} replace />;

Check failure on line 241 in src/App.tsx

View workflow job for this annotation

GitHub Actions / verify

Cannot find name 'Navigate'. Did you mean 'Navigator'?
}

function buildExplorerLink(hash: string) {
Expand Down Expand Up @@ -470,6 +470,7 @@
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<number | null>(null);
const [depositFailureCode, setDepositFailureCode] = useState<WalletServiceErrorCode | null>(null);
const [depositRequiresReconciliation, setDepositRequiresReconciliation] = useState(false);
Expand Down Expand Up @@ -586,6 +587,7 @@

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(() => {
Expand All @@ -595,8 +597,9 @@
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");
Expand All @@ -605,6 +608,23 @@
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);
})
Expand Down Expand Up @@ -1190,7 +1210,7 @@
<Route path="/a11y-audit" element={<A11yAudit />} />

<Route path={APP_ROUTES.rateLimitCard} element={<RateLimitCard />} />
<Route path={APP_ROUTES.legacyRateLimit} element={<LegacyRateLimitRedirect />} />

Check failure on line 1213 in src/App.tsx

View workflow job for this annotation

GitHub Actions / verify

Property 'legacyRateLimit' does not exist on type '{ readonly landing: "/"; readonly dashboard: "/dashboard"; readonly marketplace: "/marketplace"; readonly publish: "/publish"; readonly myApis: "/apis/my-apis"; readonly planBadge: "/apis/plan-badge"; ... 12 more ...; readonly endpointSummary: "/endpoints"; }'.

<Route path={APP_ROUTES.endpointSummary} element={<EndpointSummary />} />

Expand Down Expand Up @@ -1479,6 +1499,11 @@
<div role="status" aria-live="polite" className="sr-only">
{checkDepositStatusMessage}
</div>
{depositStage === "input" && (vaultBlockReason || vaultPreflight.status === "unverified") && (
<p id="vault-preflight-status" className="helper-text" role="status" aria-live="polite">
{vaultBlockReason ?? vaultPreflight.message}
</p>
)}
{walletAvailability === "missing" ? (
<div className="wallet-install-prompt" role="status">
<p>Install Freighter to sign this Stellar deposit.</p>
Expand Down Expand Up @@ -1518,8 +1543,10 @@
<button className="primary-button" onClick={() => setTrustlineCheckVersion((version) => version + 1)} aria-describedby="usdc-trustline-status">
{trustlineStatus === "connect" ? "Connect Freighter" : "Check USDC trustline again"}
</button>
) : vaultPreflight.status === "checking" && depositStage === "input" ? (
<button className="primary-button" disabled>Checking vault...</button>
) : (
<button className="primary-button" onClick={handleApproveTransaction} disabled={!hasValidAmount || isBusy} aria-label={depositStage === "approving" ? "Approve deposit in wallet" : depositStage === "pending" ? "Transaction submitted, waiting for confirmation" : "Approve deposit transaction"}>
<button className="primary-button" onClick={handleApproveTransaction} disabled={!hasValidAmount || isBusy || vaultBlockReason !== null} aria-describedby={vaultBlockReason ? "vault-preflight-status" : undefined} aria-label={depositStage === "approving" ? "Approve deposit in wallet" : depositStage === "pending" ? "Transaction submitted, waiting for confirmation" : "Approve deposit transaction"}>
{depositStage === "approving" ? "Approve in wallet..." : depositStage === "pending" ? "Transaction submitted..." : "Approve Transaction"}
</button>
)}
Expand Down
22 changes: 17 additions & 5 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StrKey } from "@stellar/stellar-sdk";
import { resolveVaultConfig } from "../services/vaultConfig";

/**
* Shared application constants.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/services/vaultConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
49 changes: 49 additions & 0 deletions src/services/vaultConfig.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
42 changes: 42 additions & 0 deletions src/services/vaultReads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,45 @@ export async function readWalletUsdcBalance(address: string): Promise<bigint> {
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<VaultDepositPreflight> {
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 };
});
}
34 changes: 33 additions & 1 deletion src/services/walletService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading