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
2 changes: 2 additions & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
npx --no -- commitlint --edit "$1"
2 changes: 1 addition & 1 deletion ENVIRONMENT_SETUP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ That document covers 39 service-area tables with every variable's default, requi
| [Backend — Rate Limiting](./docs/ENV_VARIABLE_MATRIX.md#8-backend--rate-limiting) | 16 |
| [Backend — Webhook Delivery](./docs/ENV_VARIABLE_MATRIX.md#16-backend--webhook-delivery) | 11 |
| [Backend — Withdrawal Recovery](./docs/ENV_VARIABLE_MATRIX.md#30-backend--withdrawal-partial-failure-recovery) | 8 |
| [Frontend — Stellar / Soroban](./docs/ENV_VARIABLE_MATRIX.md#33-frontend--stellar--soroban-vite-vite_-prefix) | 6 |
| [Frontend — Stellar / Soroban](./docs/ENV_VARIABLE_MATRIX.md#33-frontend--stellar--soroban-vite-vite_-prefix) | 8 |
| [Frontend — Sentry Monitoring](./docs/ENV_VARIABLE_MATRIX.md#36-frontend--sentry-error-monitoring) | 7 |
| [Environment-by-Environment Summary](./docs/ENV_VARIABLE_MATRIX.md#environment-by-environment-summary) | 16 |
| [Minimum Required Sets](./docs/ENV_VARIABLE_MATRIX.md#minimum-required-sets) | — |
Expand Down
1 change: 1 addition & 0 deletions contracts/vault/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "vault-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
license = "MIT"

[package.metadata]
cargo-fuzz = true
Expand Down
6 changes: 4 additions & 2 deletions docs/ENV_VARIABLE_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,12 @@ These are consumed by `scripts/fund-testnet-account.js` only — not by the runn
| Variable | Default | Req. | Description | Source |
|---|---|---|---|---|
| `VITE_SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | ✅ always | Soroban RPC endpoint for frontend | `frontend/src/config/network.ts`, `lib/stellarAccount.ts` |
| `VITE_STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | ✅ always | Network passphrase for transaction building | `frontend/src/config/network.ts`, `components/TransactionStatus.tsx` |
| `VITE_STELLAR_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | ✅ always | Network passphrase for transaction building | `frontend/src/config/network.ts` |
| `VITE_VAULT_CONTRACT_ID` | _(empty)_ | ✅ always | Vault contract ID for client-side reads | `frontend/src/config/network.ts` |
| `VITE_USDC_ISSUER` | _(empty)_ | ⬜ optional | USDC issuer public key | `frontend/src/lib/stellarAccount.ts` |
| `VITE_HORIZON_URL` | testnet horizon | ⬜ optional | Horizon URL for account data | `frontend/src/lib/stellarAccount.ts` |
| `VITE_HORIZON_URL` | testnet horizon (`https://horizon-testnet.stellar.org`); mainnet default `https://horizon.stellar.org` | ⬜ optional | Horizon endpoint for account and transaction data | `frontend/src/config/network.ts` |
| `VITE_STELLAR_EXPLORER_URL` | testnet `https://stellar.expert/explorer/testnet`; mainnet default `https://stellar.expert/explorer/public` | ⬜ optional | Stellar Expert base URL for transaction/ledger links | `frontend/src/config/network.ts`, `lib/security.ts` |
| `VITE_XLM_PRICE_API_URL` | `https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd` | ⬜ optional | XLM/USD reference-rate API | `frontend/src/config/network.ts`, `lib/vaultApi.ts` |
| `VITE_E2E_STUB_BALANCES` | _(empty)_ | ⬜ optional | Enable stub balances for E2E tests | `frontend/src/lib/vaultApi.ts`, `hooks/useTokenAllowance.ts`, `hooks/useBalanceData.ts` |

---
Expand Down
2 changes: 1 addition & 1 deletion docs/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ A shared reference for technical and product terminology used across the YieldVa
- [Access Controls — Whitelist, RBAC & Admin](#access-controls--whitelist-rbac--admin)
- [Protocol Fees, Treasury & Basis Points](#protocol-fees-treasury--basis-points)
- [RWA Shipment Tracking & Asset Provenance](#rwa-shipment-tracking--asset-provenance)
- [Smart Contracts & On-Chian Mechanics](#smart-contracts--on-chain-mechanics)
- [Smart Contracts & On-Chain Mechanics](#smart-contracts--on-chain-mechanics)
- [Math & Fixed-Point Arithmetic](#math--fixed-point-arithmetic)
- [Frontend & UI](#frontend--ui)
- [API & Backend](#api--backend)
Expand Down
19 changes: 19 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ VITE_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
VITE_VAULT_CONTRACT_ID=
VITE_FF_ANALYTICS_PAGE=true

# Optional endpoint overrides. Every variable below has a built-in default that
# is picked from VITE_STELLAR_NETWORK_PASSPHRASE, so leaving them blank keeps
# the public testnet/mainnet endpoints:
#
# VITE_HORIZON_URL Horizon endpoint used for account and transaction
# lookups.
# default: https://horizon-testnet.stellar.org (testnet)
# https://horizon.stellar.org (mainnet)
# VITE_STELLAR_EXPLORER_URL Stellar Expert base URL used for transaction and
# ledger links (see frontend/src/lib/security.ts).
# default: https://stellar.expert/explorer/testnet (testnet)
# https://stellar.expert/explorer/public (mainnet)
# VITE_XLM_PRICE_API_URL XLM/USD reference-rate API used for fiat estimates.
# default: https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd
#
#VITE_HORIZON_URL=https://horizon-testnet.stellar.org
#VITE_STELLAR_EXPLORER_URL=https://stellar.expert/explorer/testnet
#VITE_XLM_PRICE_API_URL=https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd

# Comma-separated wallet addresses granted the "admin" role in the frontend
# nav/route guards (see frontend/src/lib/roles.ts). Optional – leave blank to
# disable the admin console for all wallets. Not a security boundary: this
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/NetworkBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { useWalletNetwork } from "../hooks/useWalletNetwork";
import { useRetryState } from "../hooks/useRetryState";
import { useTranslation } from "../i18n";
import { queryClient } from "../lib/queryClient";
import { AlertTriangle, Clock, WifiOff, Wifi, X } from "./icons";
import { AlertTriangle, Clock, Wifi, X } from "./icons";
import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal";

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -98,7 +98,11 @@ function OfflineBannerView({
<span className="offline-banner__data">
{lastKnownTvl !== undefined && `TVL: ${lastKnownTvl.toLocaleString()}`}
{lastKnownTvl !== undefined && lastKnownBalance !== undefined && " · "}
{lastKnownBalance !== undefined && `Balance: ${lastKnownBalance.toFixed(2)} USDC`}
{lastKnownBalance !== undefined &&
`Balance: ${lastKnownBalance.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} USDC`}
</span>
)}
</div>
Expand Down
11 changes: 4 additions & 7 deletions frontend/src/components/NetworkSwitchNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ interface NetworkSwitchNotificationProps {
onSwitchNetwork?: (network: "testnet" | "mainnet") => Promise<void>;
}

function detectExpectedNetwork(): "testnet" | "mainnet" {
const passphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE ?? "";
return passphrase.includes("Public") ? "mainnet" : "testnet";
}

const NetworkSwitchNotification: React.FC<NetworkSwitchNotificationProps> = ({
walletAddress,
onSwitchNetwork,
Expand Down Expand Up @@ -56,10 +51,12 @@ const NetworkSwitchNotification: React.FC<NetworkSwitchNotificationProps> = ({
if (!isMismatch || isDismissed) return null;

const walletNet = walletNetwork || "Unknown";
const appNet = expectedNetwork || detectExpectedNetwork();
// `expectedNetwork` comes from `networkConfig` inside `useWalletNetwork`.
const appNet = expectedNetwork;
const isAppTestnet = appNet.toLowerCase() === "testnet";

const getManualSteps = () => {
if (appNet === "testnet") {
if (isAppTestnet) {
return [
"Open your Stellar wallet extension",
'Navigate to network settings or preferences',
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/SessionExpiryWarning.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import { AuthProvider } from "../context/AuthContext";

vi.mock("../i18n", () => ({
useTranslation: () => ({
t: (key: string) => {
t: (key: string, options?: Record<string, unknown>) => {
const interpolate = (message: string) =>
options
? message.replace(/\{\{(\w+)\}\}/g, (match, name: string) =>
options[name] !== undefined ? String(options[name]) : match,
)
: message;
if (key === "session.warning.title") return "Session Expiring Soon";
if (key === "session.warning.message") return "Your wallet session will expire in {{minutes}} minutes. Reconnect to continue without interruption.";
if (key === "session.warning.message")
return interpolate("Your wallet session will expire in {{minutes}} minutes. Reconnect to continue without interruption.");
if (key === "session.warning.reconnect") return "Reconnect";
if (key === "common.dismiss") return "Dismiss";
return key;
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/ShareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Share2, Copy, Check, MessageCircle, Twitter, Facebook } from "./icons";
import Modal from "./Modal";
import { copyTextToClipboard } from "../lib/clipboard";
import { useToast } from "../context/ToastContext";
import { socialShareBaseUrls } from "../config/externalLinks";

interface ShareModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -41,21 +42,21 @@ const ShareModal: React.FC<ShareModalProps> = ({
{
name: "Twitter",
icon: Twitter,
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(
url: `${socialShareBaseUrls.twitter}?text=${encodeURIComponent(
`Join me on YieldVault and earn stable yields on real-world assets! Use my referral code: ${referralCode}\n\n${referralLink}`
)}`,
color: "#1DA1F2",
},
{
name: "Facebook",
icon: Facebook,
url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(referralLink)}`,
url: `${socialShareBaseUrls.facebook}?u=${encodeURIComponent(referralLink)}`,
color: "#4267B2",
},
{
name: "Telegram",
icon: MessageCircle,
url: `https://t.me/share/url?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(
url: `${socialShareBaseUrls.telegram}?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(
`Join me on YieldVault and earn stable yields! Use my referral code: ${referralCode}`
)}`,
color: "#0088CC",
Expand Down
14 changes: 5 additions & 9 deletions frontend/src/components/TransactionStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { AlertCircle, Check, Loader2 } from "./icons";
import { getStellarExplorerUrl, sanitizeExternalLink } from "../lib/security";
import { networkConfig } from "../config/network";

export type ActionStatusState = "idle" | "pending" | "success" | "failure";

Expand All @@ -16,14 +17,6 @@ interface TransactionStatusProps {
status: ActionStatus;
}

function resolveNetworkMode(): "testnet" | "mainnet" {
const networkPassphrase =
import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE ?? "";
return networkPassphrase.toLowerCase().includes("public")
? "mainnet"
: "testnet";
}

const STATE_STYLES: Record<
Exclude<ActionStatusState, "idle">,
{ border: string; icon: React.ReactNode }
Expand All @@ -50,7 +43,10 @@ const TransactionStatus: React.FC<TransactionStatusProps> = ({ status }) => {
const stateStyles = STATE_STYLES[status.state];
const explorerUrl = status.txHash
? sanitizeExternalLink(
getStellarExplorerUrl(status.txHash, resolveNetworkMode()),
getStellarExplorerUrl(
status.txHash,
networkConfig.isTestnet ? "testnet" : "mainnet",
),
)
: null;

Expand Down
20 changes: 8 additions & 12 deletions frontend/src/components/TransactionStatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { Loader2, Check, AlertCircle, Copy, X } from "./icons";
import { getStellarExplorerUrl, sanitizeExternalLink } from "../lib/security";
import { networkConfig } from "../config/network";

export type TransactionStatusState = "submitting" | "confirming" | "success" | "failure";

Expand All @@ -17,16 +18,6 @@ interface TransactionStatusModalProps {
mockMode?: boolean;
}

const HORIZON_BASE_URL = "https://horizon-testnet.stellar.org";

function resolveNetworkMode(): "testnet" | "mainnet" {
const networkPassphrase =
import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE ?? "";
return networkPassphrase.toLowerCase().includes("public")
? "mainnet"
: "testnet";
}

const TransactionStatusModal: React.FC<TransactionStatusModalProps> = ({
isOpen,
onClose,
Expand Down Expand Up @@ -105,7 +96,7 @@ const TransactionStatusModal: React.FC<TransactionStatusModalProps> = ({

// Check transaction status on Stellar Horizon
const checkTxStatus = useCallback(async (hash: string): Promise<boolean> => {
const url = `${HORIZON_BASE_URL}/transactions/${hash}`;
const url = `${networkConfig.horizonUrl}/transactions/${hash}`;
try {
const response = await fetch(url);
if (response.ok) {
Expand Down Expand Up @@ -223,7 +214,12 @@ const TransactionStatusModal: React.FC<TransactionStatusModalProps> = ({
};

const explorerUrl = txHash
? sanitizeExternalLink(getStellarExplorerUrl(txHash, resolveNetworkMode()))
? sanitizeExternalLink(
getStellarExplorerUrl(
txHash,
networkConfig.isTestnet ? "testnet" : "mainnet",
),
)
: null;

return createPortal(
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/TransactionTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import TransactionTimeline from "./TransactionTimeline";
import { getStellarExplorerUrl } from "../lib/security";

/** 64-char hex hash that passes `isValidTransactionHash`. */
const VALID_TX_HASH = "a1b2c3d4".repeat(8);

describe("TransactionTimeline", () => {
it("renders aria label", () => {
Expand All @@ -25,19 +29,27 @@ describe("TransactionTimeline", () => {
render(
<TransactionTimeline
status="finalized"
txHash="abc123"
txHash={VALID_TX_HASH}
/>,
);
// Two "Finalized" labels: one in the steps list (completed) and one in the final row
const finalizedLabels = screen.getAllByText("Finalized");
expect(finalizedLabels.length).toBeGreaterThanOrEqual(1);

const explorerLink = screen.getByRole("link", { name: /View on Stellar Explorer/i });
expect(explorerLink).toHaveAttribute("href", expect.stringContaining("abc123"));
expect(explorerLink).toHaveAttribute(
"href",
getStellarExplorerUrl(VALID_TX_HASH, "testnet"),
);
expect(explorerLink).toHaveAttribute("target", "_blank");
expect(explorerLink).toHaveAttribute("rel", "noopener noreferrer");
});

it("does not render an explorer link for an invalid transaction hash", () => {
render(<TransactionTimeline status="finalized" txHash="not-a-hash" />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});

it("does not render explorer link when finalized without txHash", () => {
render(<TransactionTimeline status="finalized" />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/TransactionTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from "react";
import { Check, AlertCircle, Loader2 } from "./icons";
import { useTranslation } from "../i18n";
import { networkConfig } from "../config/network";
import { getStellarExplorerUrl } from "../lib/security";

export type TxTimelineStatus = "preparing" | "signing" | "submitting" | "pending" | "confirming" | "finalized" | "failed";

Expand Down Expand Up @@ -80,6 +82,16 @@ const TransactionTimeline: React.FC<TransactionTimelineProps> = ({
? STEPS // show all steps, the failed one will be marked failed based on order
: STEPS;

// `getStellarExplorerUrl` returns "#" for hashes that fail validation; never
// render a dead link in that case.
const explorerUrl = txHash
? getStellarExplorerUrl(
txHash,
networkConfig.isTestnet ? "testnet" : "mainnet",
)
: null;
const hasExplorerLink = Boolean(explorerUrl && explorerUrl !== "#");

return (
<div
role="status"
Expand Down Expand Up @@ -207,9 +219,9 @@ const TransactionTimeline: React.FC<TransactionTimelineProps> = ({
? errorMessage
: t(status === "finalized" ? "txTimeline.steps.finalized.desc" : "txTimeline.steps.failed.desc")}
</p>
{status === "finalized" && txHash && (
{status === "finalized" && hasExplorerLink && (
<a
href={`https://stellar.expert/explorer/testnet/tx/${txHash}`}
href={explorerUrl ?? "#"}
target="_blank"
rel="noopener noreferrer"
style={{
Expand Down
16 changes: 9 additions & 7 deletions frontend/src/components/VaultDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,13 @@ describe("VaultDashboard", () => {

expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Withdraw" }));
fireEvent.click(screen.getByRole("tab", { name: "Withdraw" }));
await waitFor(() => {
expect(screen.getByTestId("location-search")).toHaveTextContent("tab=withdraw");
expect(screen.getByText(/Amount to withdraw/i)).toBeInTheDocument();
});

fireEvent.click(screen.getByRole("button", { name: "Deposit" }));
fireEvent.click(screen.getByRole("tab", { name: "Deposit" }));
await waitFor(() => {
expect(screen.getByTestId("location-search")).toHaveTextContent("tab=deposit");
expect(screen.getByText(/Amount to deposit/i)).toBeInTheDocument();
Expand Down Expand Up @@ -353,7 +353,7 @@ describe("VaultDashboard", () => {

expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Withdraw" }));
fireEvent.click(screen.getByRole("tab", { name: "Withdraw" }));
await waitFor(() => {
expect(screen.getByTestId("location-search")).toHaveTextContent("tab=withdraw");
});
Expand Down Expand Up @@ -491,7 +491,7 @@ describe("VaultDashboard", () => {
fireEvent.change(input, { target: { value: "100" } });
expect(input).toHaveValue(100);

fireEvent.click(screen.getByRole("button", { name: "Withdraw" }));
fireEvent.click(screen.getByRole("tab", { name: "Withdraw" }));

await waitFor(() => {
expect(screen.getByTestId("location-search")).toHaveTextContent("tab=withdraw");
Expand Down Expand Up @@ -569,7 +569,7 @@ describe("VaultDashboard", () => {
await screen.findByRole("heading", { name: "Transaction Successful" });
}, 15000);

it("does not offer a retry action for a non-retryable validation failure", async () => {
it("routes a non-retryable validation failure back to the amount step without a retry action", async () => {
const validationError = new ValidationError({
message: "Validation failed",
userMessage: "Please review the amount and try again.",
Expand All @@ -584,8 +584,10 @@ describe("VaultDashboard", () => {
fireEvent.click(screen.getByRole("button", { name: "Review Transaction" }));
fireEvent.click(await screen.findByRole("button", { name: /Confirm deposit/i }));

await screen.findByRole("heading", { name: "Transaction Failed" });
expect(await screen.findByRole("button", { name: "Start Over" })).toBeInTheDocument();
// Field-level validation failures send the user back to the amount step
// so the input can be corrected; a blind retry is never offered.
expect(await screen.findByText("Amount exceeds vault cap")).toBeInTheDocument();
expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
}, 15000);

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/config/externalLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Third-party link endpoints that are not network-specific. They live here
* instead of inline in components so a deployment can swap them in one place.
*/
export const socialShareBaseUrls = {
twitter: "https://twitter.com/intent/tweet",
facebook: "https://www.facebook.com/sharer/sharer.php",
telegram: "https://t.me/share/url",
} as const;
Loading
Loading