diff --git a/README.md b/README.md index 974829e..6fe944d 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,9 @@ If either party isn't KYC-approved the transaction reverts. The transfer form in `/asset/[id]` reads this status up front and disables the action with a clear reason when the connected wallet can't transfer. -### Network & deployed contracts (Testnet) +### Networks & deployed contracts -Network passphrase: `Test SDF Network ; September 2015` · RPC: -`https://soroban-testnet.stellar.org` +**Testnet** · Network passphrase: `Test SDF Network ; September 2015` · Default RPC: `https://soroban-testnet.stellar.org` | Contract | Contract ID | Explorer | |-------------|-------------|----------| @@ -72,8 +71,9 @@ Network passphrase: `Test SDF Network ; September 2015` · RPC: | dividend | `CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX` | [view](https://stellar.expert/explorer/testnet/contract/CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX) | | asset-token (sample) | `CBMCWLSQSWUTLUJFCNBHNBSXMUM3XU7NAQ5TSNERW4HA4ZZBYHLG4ECZ` | [view](https://stellar.expert/explorer/testnet/contract/CBMCWLSQSWUTLUJFCNBHNBSXMUM3XU7NAQ5TSNERW4HA4ZZBYHLG4ECZ) | -Registry, compliance and dividend ids are configured via `NEXT_PUBLIC_*` env vars; -per-asset token ids are discovered at runtime from the registry. +Registry, compliance and dividend contract IDs are configured via environment variables (see [Environment Variables](docs/environment-variables.md#contract-ids)). Per-asset token IDs are discovered at runtime from the registry. + +To point the app at different deployments (e.g., local, alternative RPC, or Mainnet), see [Environment Variables](docs/environment-variables.md) and [Custom RPC Setup](docs/custom-rpc.md). ## Getting started diff --git a/docs/custom-rpc.md b/docs/custom-rpc.md new file mode 100644 index 0000000..a210c5c --- /dev/null +++ b/docs/custom-rpc.md @@ -0,0 +1,103 @@ +# Custom and Local RPC Setup + +By default, the app connects to the public Stellar Soroban RPC endpoints. For development or testing against a local or custom RPC instance, you can override these endpoints via environment variables. + +## Quick Start: Local Soroban Network + +To run the app against a **local Soroban quickstart** (standalone network): + +1. **Start your local Soroban network:** + ```bash + docker run --rm -it \ + -p 8000:8000 \ + stellar/quickstart:latest \ + --standalone + ``` + This exposes a local Soroban RPC at `http://localhost:8000`. + +2. **Set the environment variable in `.env.local`:** + ``` + NEXT_PUBLIC_TESTNET_RPC_URL=http://localhost:8000 + NEXT_PUBLIC_DEFAULT_NETWORK=testnet + ``` + +3. **Start the app:** + ```bash + npm run dev + ``` + +The app now talks to your local network. Since the local network has no deployed contracts, you'll need to deploy the registry, compliance, and dividend contracts there first, then set their IDs: + +``` +NEXT_PUBLIC_TESTNET_REGISTRY_ID= +NEXT_PUBLIC_TESTNET_COMPLIANCE_ID= +NEXT_PUBLIC_TESTNET_DIVIDEND_ID= +``` + +## Custom Remote RPC Provider + +To point the app at a different RPC provider (e.g., a private or alternative public endpoint): + +``` +NEXT_PUBLIC_TESTNET_RPC_URL=https://your-custom-rpc.example.com +``` + +## Failover / Multiple RPC URLs + +The app supports RPC failover: if the primary RPC URL fails **three consecutive times**, the app automatically switches to the next URL in the fallback list. + +To configure multiple RPC URLs for redundancy: + +``` +NEXT_PUBLIC_TESTNET_RPC_URL=https://primary-rpc.example.com +NEXT_PUBLIC_TESTNET_RPC_URLS_FALLBACK=https://fallback-1.example.com,https://fallback-2.example.com +``` + +The app tries URLs in order: +1. **Primary:** `https://primary-rpc.example.com` +2. **Fallback 1:** `https://fallback-1.example.com` (after 3 failures against primary) +3. **Fallback 2:** `https://fallback-2.example.com` (after 3 failures against fallback 1) +4. **Public default:** `https://soroban-testnet.stellar.org` (always available as a last resort) + +### How Failover Works + +- Each RPC error increments a failure counter for the current URL. +- After **3 consecutive failures**, the cached RPC client is invalidated and rebuilt against the next URL in the list, with the failure counter reset. +- If only one URL is configured (or all URLs are exhausted), the failure counter still increments but no failover occurs—errors bubble up to the UI. +- The counter is **per network** (Testnet and Mainnet track failures independently) and **per browser session** (resets on page reload). + +### Example: High-Availability Setup + +``` +NEXT_PUBLIC_TESTNET_RPC_URL=https://rpc-1.myinfra.com +NEXT_PUBLIC_TESTNET_RPC_URLS_FALLBACK=https://rpc-2.myinfra.com,https://rpc-3.myinfra.com,https://soroban-testnet.stellar.org +NEXT_PUBLIC_MAINNET_RPC_URL=https://mainnet-rpc-1.myinfra.com +NEXT_PUBLIC_MAINNET_RPC_URLS_FALLBACK=https://mainnet-rpc-2.myinfra.com,https://mainnet.sorobanrpc.com +``` + +## Mixed HTTP/HTTPS + +The app allows unencrypted HTTP URLs **only** for localhost or testing (useful for local Docker networks). Public URLs must be HTTPS. + +``` +# Allowed (local testing) +NEXT_PUBLIC_TESTNET_RPC_URL=http://localhost:8000 + +# Rejected (insecure public URL) +# NEXT_PUBLIC_TESTNET_RPC_URL=http://example.com <- This will fail +``` + +## Troubleshooting + +### "ECONNREFUSED" or "No route to host" + +- Verify the RPC URL is reachable: `curl -s https://your-rpc.example.com/health` +- Check firewall rules and DNS resolution. + +### High latency on list views + +- Check if `NEXT_PUBLIC_API_URL` is set and responsive. If missing or slow, list operations fall back to simulating every read against RPC, which is much slower. + +### "The contract call could not be completed" + +- This usually means the RPC is reachable but the contract ID doesn't exist there. Verify you've set the correct contract IDs for this network and that they're actually deployed. diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 0000000..02641d6 --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,176 @@ +# Environment Variables + +All configuration is via `NEXT_PUBLIC_*` environment variables, making them available to the browser. These control the network, RPC endpoints, deployed contract IDs, and an optional read aggregation API. + +Copy [.env.example](.env.example) to `.env.local` and edit as needed. + +## Network & RPC Configuration + +### `NEXT_PUBLIC_DEFAULT_NETWORK` + +- **Purpose:** The Stellar network the app connects to by default when no wallet is connected (read-only browsing mode) or after a fresh page load. +- **Required:** No (defaults to `"testnet"`) +- **Format:** String: `"testnet"` or `"mainnet"` +- **Example:** + ``` + NEXT_PUBLIC_DEFAULT_NETWORK=testnet + ``` + +### `NEXT_PUBLIC_TESTNET_RPC_URL` + +- **Purpose:** Primary Soroban RPC endpoint for Testnet reads and writes. This is tried first for all RPC operations. +- **Required:** No (defaults to `https://soroban-testnet.stellar.org`) +- **Format:** HTTPS URL +- **Example:** + ``` + NEXT_PUBLIC_TESTNET_RPC_URL=https://soroban-testnet.stellar.org + ``` + +### `NEXT_PUBLIC_TESTNET_RPC_URLS_FALLBACK` + +- **Purpose:** Comma-separated list of fallback Soroban RPC endpoints for Testnet. These are tried in order after the primary URL fails consecutively (see [custom RPC setup](./custom-rpc.md)). +- **Required:** No (defaults to empty; only the primary URL is used if not set) +- **Format:** Comma-separated HTTPS URLs +- **Example:** + ``` + NEXT_PUBLIC_TESTNET_RPC_URLS_FALLBACK=https://alternative-rpc-1.example.com,https://alternative-rpc-2.example.com + ``` + +### `NEXT_PUBLIC_MAINNET_RPC_URL` + +- **Purpose:** Primary Soroban RPC endpoint for Mainnet reads and writes. +- **Required:** No (defaults to `https://mainnet.sorobanrpc.com`) +- **Format:** HTTPS URL +- **Example:** + ``` + NEXT_PUBLIC_MAINNET_RPC_URL=https://mainnet.sorobanrpc.com + ``` + +### `NEXT_PUBLIC_MAINNET_RPC_URLS_FALLBACK` + +- **Purpose:** Comma-separated list of fallback Soroban RPC endpoints for Mainnet, tried in order after the primary URL fails consecutively. +- **Required:** No (defaults to empty) +- **Format:** Comma-separated HTTPS URLs +- **Example:** + ``` + NEXT_PUBLIC_MAINNET_RPC_URLS_FALLBACK=https://mainnet-alt-1.example.com,https://mainnet-alt-2.example.com + ``` + +## Contract IDs + +The app invokes four RWA contracts: **registry** (asset index), **compliance** (KYC gate), **dividend** (yield distribution), and individual **asset tokens** (discovered at runtime from the registry). + +The first three are configured via environment variables and are network-specific; asset token IDs are not configurable—they are discovered at runtime. + +### Testnet Contract IDs + +All three Testnet contract IDs have hardcoded defaults that ship with the app, matching the official deployments. These can be overridden via environment variables. + +#### `NEXT_PUBLIC_TESTNET_REGISTRY_ID` + +- **Purpose:** Testnet registry contract ID — the authoritative index of tokenized assets and their total value locked. +- **Required:** No (defaults to the official Testnet deployment: `CBX5SMLTXX6JP4HA5GQIO2V6QM7WCUGL2GZ6D4U773HMRI6RXISKPUR3`) +- **Format:** Stellar contract ID (starts with 'C', 56 characters) +- **Example:** + ``` + NEXT_PUBLIC_TESTNET_REGISTRY_ID=CBX5SMLTXX6JP4HA5GQIO2V6QM7WCUGL2GZ6D4U773HMRI6RXISKPUR3 + ``` + +#### `NEXT_PUBLIC_TESTNET_COMPLIANCE_ID` + +- **Purpose:** Testnet compliance contract ID — enforces KYC allowlists and jurisdiction rules, checked on every token transfer. +- **Required:** No (defaults to the official Testnet deployment: `CBUERYDM7DXTZLLKDBRJKUBPFJ7M4OSUN4T7XKUARU345RLXNAIQD2IU`) +- **Format:** Stellar contract ID (starts with 'C', 56 characters) +- **Example:** + ``` + NEXT_PUBLIC_TESTNET_COMPLIANCE_ID=CBUERYDM7DXTZLLKDBRJKUBPFJ7M4OSUN4T7XKUARU345RLXNAIQD2IU + ``` + +#### `NEXT_PUBLIC_TESTNET_DIVIDEND_ID` + +- **Purpose:** Testnet dividend contract ID — creates and manages proportional dividend/yield distributions to token holders. +- **Required:** No (defaults to the official Testnet deployment: `CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX`) +- **Format:** Stellar contract ID (starts with 'C', 56 characters) +- **Example:** + ``` + NEXT_PUBLIC_TESTNET_DIVIDEND_ID=CAR4XY3CEBQWFOL27JEWFW34KXSIZA7RFKDQMEIV7ZU723RWY37I2SYX + ``` + +### Mainnet Contract IDs + +All three Mainnet contract IDs default to empty strings, meaning the app **will not function** on Mainnet until these are explicitly set. This is intentional: it prevents accidentally pointing at undeployed contracts. + +#### `NEXT_PUBLIC_MAINNET_REGISTRY_ID` + +- **Purpose:** Mainnet registry contract ID. +- **Required:** No, but the app will not work on Mainnet without it (defaults to empty string) +- **Format:** Stellar contract ID (starts with 'C', 56 characters) +- **Example:** + ``` + NEXT_PUBLIC_MAINNET_REGISTRY_ID=CBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + ``` + +#### `NEXT_PUBLIC_MAINNET_COMPLIANCE_ID` + +- **Purpose:** Mainnet compliance contract ID. +- **Required:** No, but the app will not work on Mainnet without it (defaults to empty string) +- **Format:** Stellar contract ID (starts with 'C', 56 characters) +- **Example:** + ``` + NEXT_PUBLIC_MAINNET_COMPLIANCE_ID=CBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + ``` + +#### `NEXT_PUBLIC_MAINNET_DIVIDEND_ID` + +- **Purpose:** Mainnet dividend contract ID. +- **Required:** No, but the app will not work on Mainnet without it (defaults to empty string) +- **Format:** Stellar contract ID (starts with 'C', 56 characters) +- **Example:** + ``` + NEXT_PUBLIC_MAINNET_DIVIDEND_ID=CBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + ``` + +## Read Aggregation API (Optional) + +### `NEXT_PUBLIC_API_URL` + +- **Purpose:** Optional URL of a Stellar RWA API server that provides faster read aggregations (list views, statistics, holder counts). When set, the app reads these aggregations from this endpoint instead of simulating every read directly against Soroban RPC, which is slower and more expensive. **Writes (signing transactions) always go through RPC regardless of this setting.** +- **Required:** No (defaults to empty; all reads fall back to direct RPC simulations) +- **Format:** HTTPS URL (base URL; the app appends paths like `/assets`, `/stats`, `/holders`) +- **Example:** + ``` + NEXT_PUBLIC_API_URL=https://rwa-api.example.com + ``` +- **Fallback behavior:** When `NEXT_PUBLIC_API_URL` is not set or the API is unreachable, the app automatically falls back to reading directly from Soroban RPC. No UI change is visible to the user—reads simply take longer. + +## App Metadata (Optional) + +These are typically set automatically by the build system and are used only in the footer for version tracking and debugging. + +### `NEXT_PUBLIC_APP_VERSION` + +- **Purpose:** The semantic version of the app displayed in the footer. +- **Required:** No (defaults to the `version` field in `package.json` at build time) +- **Format:** Semantic version (e.g., `1.0.0`, `1.2.3-alpha`) +- **Example:** + ``` + NEXT_PUBLIC_APP_VERSION=1.0.0 + ``` +- **Note:** If not set, `next.config.mjs` reads the version from `package.json` during the build. + +### `NEXT_PUBLIC_APP_COMMIT` + +- **Purpose:** The git commit hash of the deployed build, displayed in the footer for correlating user-reported bugs with a specific code version. +- **Required:** No (defaults to resolution from environment, then `git rev-parse --short HEAD`) +- **Format:** Git commit hash (full or short; only the first 7 characters are displayed) +- **Example:** + ``` + NEXT_PUBLIC_APP_COMMIT=abc1234 + ``` +- **Resolution order at build time:** + 1. `NEXT_PUBLIC_APP_COMMIT` (explicit override) + 2. `VERCEL_GIT_COMMIT_SHA` (set by Vercel on deploy) + 3. `GIT_COMMIT_SHA` (set by some CI systems) + 4. `COMMIT_REF` (set by other CI systems) + 5. `git rev-parse --short HEAD` (local git history, if available) + 6. Undefined (if none of the above are available) diff --git a/docs/wallet-connection.md b/docs/wallet-connection.md new file mode 100644 index 0000000..9b8bba3 --- /dev/null +++ b/docs/wallet-connection.md @@ -0,0 +1,199 @@ +# Wallet Connection Flow + +The app integrates with the **Freighter wallet** browser extension for transaction signing and account management. This document describes the connection flow, all the states the app handles, and what happens in each failure mode. + +## Overview + +Wallet connection is optional—the app can be used in **read-only mode** to browse assets without signing in. When the user clicks "Connect Wallet" or performs an action that requires signing (transferring a token, creating a distribution, etc.), the app attempts to establish a connection to Freighter. + +``` +Disconnected (read-only) ──► [Connect Wallet] ──► Freighter prompt ──► Connected + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + Not installed User rejects Wrong network + │ │ │ + Error msg Error msg Connected* + (retry) (retry) (*sync required) +``` + +## Initial State: Freighter Detection (Mount) + +When the app loads, it **asynchronously checks** whether Freighter is installed. This check is non-blocking—the app renders immediately even if Freighter detection is slow. + +- **If Freighter is installed:** + - The "Connect Wallet" button changes to show "Connect Wallet" (prompting an explicit connection). + - If the user previously connected to this app (flag stored in localStorage), the app attempts to silently restore the prior session—checking if the app still has permission and the account is still available. This requires no user interaction. + +- **If Freighter is not installed:** + - The "Connect Wallet" button changes to "Get Freighter" and links to https://www.freighter.app. + - The app operates in read-only mode. + +## Connection Flow + +When the user clicks "Connect Wallet" or "Get Freighter": + +### 1. Check Freighter Installation +```typescript +const installed = await isFreighterInstalled() +``` +Returns `true` if the Freighter extension responds; `false` otherwise or on error. + +**Outcome:** +- ✅ Installed → proceed to step 2 +- ❌ Not installed → show "Get Freighter" link and button + +### 2. Prompt for Account Access +```typescript +const address = await connect() // Shows Freighter popup +``` +Freighter displays a popup asking the user to: +- Select which account to connect +- Grant this app permission to read the account and sign transactions + +**Possible outcomes:** +- ✅ **User approves** → Account address returned, proceed to step 3 +- ❌ **User rejects** → `WalletError("user rejected")` thrown, show error message +- ❌ **Freighter not responding** → `WalletError` thrown (network/extension issue), show error message + +### 3. Determine Wallet Network +```typescript +const walletNetwork = await getWalletNetwork() +``` +Checks which Stellar network Freighter is currently pointed at (Testnet or Mainnet). + +**Possible outcomes:** +- ✅ **Network is known** (testnet or mainnet) → Connected ✓ +- ⚠️ **Network is unknown** (unfamiliar passphrase) → Connected but with a warning; writes are blocked until sync succeeds + +### 4. Sync App Network to Wallet Network (Connected) +Once connected, the app's network **follows Freighter's network** so reads and writes always agree. If the user switches networks inside the Freighter extension, the app detects it and updates automatically (polling every ~8 seconds). + +## Failure Modes + +All four failure modes the app handles are described below. + +### ❌ Freighter Not Installed + +**When this happens:** User clicks "Connect Wallet" and Freighter is not detected. + +**What the user sees:** +- Connection button changes to "Get Freighter" with a link to https://www.freighter.app +- Error message appears: *"Freighter wallet not detected. Install it from freighter.app to continue."* + +**What a developer should know:** +- Detection is attempted at mount (non-blocking). +- The app continues to function in read-only mode while Freighter is not installed. +- Installing Freighter and reloading the page resolves this. + +**Code path:** `lib/freighter.ts` → `isFreighterInstalled()` returns `false`, caught in `components/wallet/ConnectButton.tsx` and shows "Get Freighter" button. + +### ❌ Freighter Locked + +**When this happens:** Freighter is installed but locked (user hasn't unlocked it yet, or it's been locked by the browser). + +**What the user sees:** +- Connection button shows spinner briefly, then error message appears: *"Freighter wallet locked."* or *"User rejected the connection."* +- A "Try again" button appears to retry the connection. + +**What a developer should know:** +- This is reported by Freighter as a rejected request (same error as if the user explicitly rejected). +- The user must unlock Freighter in their browser extension UI and retry. +- The app does not retry automatically—the user must click "Try again". + +**Code path:** `lib/freighter.ts` → `connect()` → `fRequestAccess()` returns `{ error: "..." }`, caught in `hooks/useWallet.tsx` and displayed as an error message. + +### ❌ Wrong Network + +**When this happens:** User connects successfully, but Freighter is pointed at a different network than the app expects. + +**Example scenario:** +- App default is Testnet. +- User points Freighter at Mainnet. +- User clicks "Connect Wallet". +- Connection succeeds, but the network check detects a mismatch. + +**What the user sees:** +- Connection succeeds (address shown). +- **However**, writes are disabled and show: *"Can't verify your wallet's network. Reconnect and try again."* +- The UI displays a state `networkUnknown: true` and hides write-requiring actions. + +**What a developer should know:** +- Connected does NOT mean ready to sign. A connected state with `networkUnknown === true` is a warning state. +- The app polls Freighter every ~8 seconds to detect network changes. If the user switches Freighter to the correct network, writes automatically become available (no reconnection needed). +- If the app's active network is Testnet but Freighter is on Mainnet, the write will fail with "Can't verify your wallet's network." +- This is a safeguard to prevent accidentally signing transactions on the wrong network. + +**Possible causes:** +- Freighter's network identity cannot be determined (unfamiliar passphrase or API failure). +- Freighter's network doesn't match the app's current network. + +**Code path:** `lib/freighter.ts` → `getWalletNetwork()` returns `null`, captured in `hooks/useWallet.tsx` as `networkUnknown: true`. Write context throws in `lib/contracts.ts` when `writeCtx()` is called. + +### ❌ User Rejected + +**When this happens:** User clicks "Reject" on the Freighter account access prompt. + +**What the user sees:** +- Connection is cancelled. +- Error message appears: *"User rejected the connection request."* or similar. +- A "Try again" button appears. + +**What a developer should know:** +- Freighter returns an error (not an exception), captured and re-thrown as a `WalletError`. +- The app does not retry automatically—the user must click "Try again". +- If the user repeatedly rejects, they must dismiss the error and try again or reload the page to be prompted once more. + +**Code path:** `lib/freighter.ts` → `connect()` → `fRequestAccess()` returns `{ error: "..." }`, thrown as `WalletError`, caught in `hooks/useWallet.tsx`. + +## Error Boundary + +If the Freighter API throws an uncaught exception during wallet initialization (e.g., a rare extension crash), the **error boundary** `WalletErrorBoundary` catches it: + +- The entire app tree still renders in read-only mode (no blank screen). +- A banner appears at the bottom: *"Wallet failed to load — browsing in read-only mode."* with a "Retry" button. +- Clicking "Retry" attempts to re-initialize the wallet context. + +**Code path:** `hooks/useWallet.tsx` → `WalletErrorBoundary` component. + +## Polling & Background Updates + +The app **actively polls Freighter** to detect wallet state changes (account/network switches) made inside the Freighter extension while the app is open: + +- **Poll interval:** ~8 seconds +- **Frequency:** Active only when the tab is visible (paused when the tab is hidden to conserve battery/CPU). +- **Trigger:** On every network change, the app updates the `walletNetwork` state. If connected, the app's active network follows the wallet. If disconnected, the app's active network remains user-selectable for read-only browsing. + +**Code path:** `hooks/useWallet.tsx` → `watchWallet()` → `lib/freighter.ts` → `WatchWalletChanges`. + +## Session Persistence + +When the user connects, the app stores a flag in `localStorage` (`rwa.wallet.connected`). On the next page load: + +1. If the flag exists, the app silently checks if the prior account is still available (`getConnectedAddress()`). +2. If available and still permitted, the session is restored without a prompt. +3. If not available or permission was revoked, the flag is cleared and the user must reconnect. + +**Code path:** `hooks/useWallet.tsx` → effect at line 143. + +## Summary Table + +| State | User Sees | Writes Allowed | Action | +|-------|-----------|---|---------| +| **Disconnected** | "Connect Wallet" button | ❌ No | Read-only browsing; click button to connect | +| **Freighter not installed** | "Get Freighter" link | ❌ No | Install extension from freighter.app and reload | +| **Connecting** | Spinner in button | ❌ No (pending) | Waiting for Freighter response | +| **Connected (network known)** | Address + dropdown menu | ✅ Yes | Can read and write | +| **Connected (network unknown)** | Address + error message | ❌ No | Network mismatch; switch Freighter's network or reconnect | +| **Connection rejected** | Error message + retry button | ❌ No | User rejected in Freighter; click "Try again" to retry | +| **Error boundary active** | Read-only mode + retry banner | ❌ No | Wallet crashed; click "Retry" button to reinitialize | + +## Testing Connection States Locally + +To test various states without a real wallet: + +1. **Not installed:** Uninstall Freighter, reload the app. +2. **Locked:** Lock Freighter in your browser, click "Connect Wallet". +3. **User rejected:** Click "Connect Wallet", then click "Reject" in the Freighter prompt. +4. **Wrong network:** Connect to Testnet, then switch Freighter to Mainnet. The app detects this within ~8 seconds and blocks writes. +5. **Error boundary:** Open browser dev tools, set a breakpoint in the Freighter API call, and throw an exception—this triggers the error boundary.