diff --git a/.env.example b/.env.example index ed9e8b14..7f662707 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,12 @@ VITE_STELLAR_RPC_URL= # cannot be submitted 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 +# defaults to GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ. +# Leave commented to use that default. Mainnet/futurenet require an explicit +# issuer for their vault. Invalid or empty keys block trustline/deposit actions. +# VITE_STELLAR_USDC_ISSUER=GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ + # Dev server only: set to `true` to show the deposit demo-outcome toggle. VITE_ENABLE_DEMO_OUTCOME=false diff --git a/docs/Configuration.md b/docs/Configuration.md index d1b9042e..a308bbc3 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -63,6 +63,25 @@ in development, because a silently wrong network is worse than a loud warning. Resolve it through `resolveStellarNetwork()` rather than reading the raw variable. +### `VITE_STELLAR_USDC_ISSUER` + +Classic issuer public key for the `USDC` asset backing the configured vault's +Stellar Asset Contract. On testnet, the default is +`GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ`, backing token +`CAC7CFZ42L3LTTZCI4C3XI6UF7AHS2PYTNJ5QEG2PSLRGDGMWRQOZOT5`. +Override it only with the issuer used by the vault on the selected network. +Mainnet and futurenet require an explicit issuer; they do not inherit the +testnet issuer. Surrounding whitespace is trimmed. An invalid or explicitly +empty key produces a configuration error before a trustline lookup or signature +request. Validation uses `StrKey.isValidEd25519PublicKey`. + +`STELLAR_USDC_ASSET` exports `{ code: "USDC", issuer }`. +`STELLAR_USDC_CONFIGURATION_ERROR` exposes validation failures to the modal. +`STELLAR_HORIZON_URL` follows `STELLAR_NETWORK`: testnet uses +`https://horizon-testnet.stellar.org`, mainnet uses `https://horizon.stellar.org`, +and futurenet uses `https://horizon-futurenet.stellar.org`. +See [Deposit flow](Deposit-Flow.md) for the trustline and funding steps. + ### `VITE_DEV_API_PROXY_TARGET` Where the dev server forwards `/api` requests. Default: @@ -124,6 +143,9 @@ relative is what makes that work without a rebuild per environment. | `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_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` | | `ENABLE_DEMO_OUTCOME` | `VITE_ENABLE_DEMO_OUTCOME === "true"` on the dev server only | | `EXPLORER_BASE_URL` | stellar.expert transaction URL for `STELLAR_NETWORK` | diff --git a/docs/Deposit-Flow.md b/docs/Deposit-Flow.md index 84c78121..b8e0aaa7 100644 --- a/docs/Deposit-Flow.md +++ b/docs/Deposit-Flow.md @@ -1,57 +1,124 @@ -# Deposit flow contract - -This describes the current **demo** deposit modal on `/billing`. The source of truth is `src/App.tsx`; values shared with the UI come from `src/config/constants.ts`. It is a reference for the wallet integration, not a claim that a wallet transaction or backend deposit exists today. - -## Stages and transitions +# Deposit flow + +The `/billing` deposit modal signs transactions with Freighter and submits vault +deposits through Stellar RPC. Shared configuration lives in +`src/config/constants.ts`; wallet operations live in `src/services/walletService.ts`. + +## Asset and network configuration + +`STELLAR_USDC_ASSET` is `{ code: "USDC", issuer }`. The testnet default issuer is +`GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ`, whose Stellar Asset +Contract is `CAC7CFZ42L3LTTZCI4C3XI6UF7AHS2PYTNJ5QEG2PSLRGDGMWRQOZOT5`. +`VITE_STELLAR_USDC_ISSUER` overrides the issuer and is validated with +`StrKey.isValidEd25519PublicKey`. Mainnet and futurenet require an explicit issuer. +Invalid configuration blocks trustline and deposit actions before Horizon is called. +The configured vault must accept this asset; a public key alone cannot prove that +it matches the vault. See [Configuration](Configuration.md) and `.env.example`. + +Freighter must use `STELLAR_NETWORK_PASSPHRASE`. `STELLAR_HORIZON_URL` selects the +same network for classic account lookups and trustline submission. Vault deposits +require `VITE_STELLAR_VAULT_CONTRACT_ID` and the configured Soroban RPC endpoint. + +## Trustline step + +Opening the modal checks Freighter, connects the selected account, verifies its +network, then calls `hasUsdcTrustline(address)`. It fetches Horizon +`GET /accounts/{address}` and matches **both** `asset_code` and `asset_issuer` in +`balances`. A zero USDC balance still counts as an existing trustline. + +| Result | Modal behavior | +| --- | --- | +| `present` | Show **Approve Transaction** for a valid amount. | +| `missing` | Replace approval with **Add USDC trustline** and explain the additional **0.5 XLM base reserve**, plus the transaction fee. | +| `unfunded-account` | Horizon returned 404. Explain XLM funding and offer **Check USDC trustline again**. Show an account-specific Friendbot link only when `STELLAR_NETWORK === "testnet"`. | +| `unknown` | A network/HTTP/invalid-response failure prevents checking. Show a warning and allow approval. | + +The trustline step uses native keyboard-operable buttons and links. Its status is +announced through a polite, atomic live region, linked to the action with +`aria-describedby`. Rejected wallet access offers **Connect Freighter** again. + +**Add USDC trustline** reconnects the wallet and verifies the network, loads the +classic account sequence, and builds a transaction containing exactly one +`Operation.changeTrust` for `STELLAR_USDC_ASSET`. It has a bounded lifetime and uses +the configured passphrase when signing in Freighter. No payment, mint, deposit, +or other operation is included. Horizon's synchronous `POST /transactions` endpoint +receives the signed XDR and returns after ledger inclusion. Only a response with +`successful: true` counts as success. The modal then rechecks the selected account +before enabling deposit approval. + +While adding, controls prevent duplicate submissions or closing the modal. A +rejected signature is a normal cancellation, detected by the existing +`isUserRejection` helper: stay at the trustline step without an error toast. +Other failures remain inline and allow another trustline check before retrying. +A trustline reserves XLM; it does not provide USDC. Minting/faucet logic for USDC +and live wallet balance display are outside this flow. + +## Deposit stages and amounts ```mermaid stateDiagram-v2 - [*] --> input: Open deposit modal / reset flow - input --> input: Change amount, select preset or Max - input --> approving: Approve Transaction (valid amount) - approving --> pending: 1.4 s demo timer - pending --> confirmed: 3.6 s demo timer; demoOutcome = confirmed - pending --> failed: 3.6 s demo timer; demoOutcome = failed - failed --> input: Retry deposit - confirmed --> input: Deposit another amount (reset to 50 USDC) - input --> [*]: Close, Cancel, backdrop, or leave /billing - confirmed --> [*]: Close, Cancel, backdrop, or leave /billing - failed --> [*]: Close, Cancel, backdrop, or leave /billing + [*] --> input: Open modal and check trustline + input --> trustline: Missing trustline + trustline --> input: Confirm changeTrust and recheck + trustline --> trustline: Cancel signature or retry + input --> approving: Approve valid amount (present or unknown) + approving --> pending: Stellar RPC accepts transaction and returns hash + pending --> confirmed: RPC confirms success + approving --> failed: Rejection or submission failure + pending --> failed: Failure or confirmation timeout + failed --> input: Retry after definitive failure + confirmed --> input: Deposit another amount ``` -`approving` and `pending` are busy states: amount, preset, Max, Close, and Cancel controls are disabled, and the close handler refuses to dismiss the modal. The `pending` timer is scheduled when approval starts, so its 1.4-second delay is measured from the click, not from a wallet callback. The final timer is also measured from that click. The stage strip shows the selected demo outcome as its final stage. - -Opening from the billing page, a preset, or `/billing?deposit=true` resets the stage to `input`, clears the transaction hash and captured submission, and preserves the current amount unless a preset was supplied. Closing removes the `deposit` query parameter when present. Leaving `/billing` hides the modal; unmounting the app clears scheduled timers. - -**Current wiring caveat:** the billing page's “Open deposit modal” button passes `openDeposit` directly as `onClick`. React therefore supplies its click event as the optional `presetAmount`; that path can put a nonnumeric event string into the amount field. Other callers invoke `openDeposit()` or pass a numeric preset explicitly. The wallet integration should wrap the billing click handler so this demo wiring error is not treated as part of the deposit contract. - -| Stage | Status banner heading | Status message and visible result | -| --- | --- | --- | -| `input` | “Enter a deposit amount” until valid, then “Review transaction preview” | “Deposit funds to keep premium calls and AI workflows funded without leaving the dashboard.” The preview is editable; invalid input shows a validation message. | -| `approving` | “Approve in wallet...” | “Approve this USDC deposit in your wallet to continue.” The primary button reads “Approve in wallet...” and is disabled. This is a simulated wait; no wallet prompt is opened. | -| `pending` | “Transaction submitted...” | “Transaction submitted to Stellar. Waiting for confirmation.” A shortened transaction hash, copy action, and testnet explorer link appear. The primary button reads “Transaction submitted...” and is disabled. | -| `confirmed` | “Deposit successful” | “{amount} reached the vault. Your balance is updated and ready for API usage.” The demo adds the captured amount to the captured starting vault balance, rounded to two decimals. A success card shows the new balance. | -| `failed` | “Transaction failed” | “The deposit was not confirmed. Review the details, then retry when your wallet is ready.” A failure card says no funds were added and offers **Retry deposit**. | - -## Amount and preview rules - -The amount starts at `50` USDC. `src/config/constants.ts` defines `MIN_DEPOSIT = 10`, `PRESET_AMOUNTS = [10, 50, 100, 500]`, and the displayed `NETWORK_FEE = "0.00001 XLM"`. The current wallet balance is an in-memory `1260.5` USDC value in `App.tsx`; it is the upper limit, not a constant exported by config. **Max** fills the input with that balance to two decimal places. - -The input handler removes every character except digits and periods. Validation then checks, in order: blank input (“Enter a deposit amount to continue.”), non-finite numeric conversion (“Amount must be a valid number.”), an amount below `MIN_DEPOSIT` (“Minimum deposit is $10.”), and an amount above the available wallet balance (“Amount exceeds available wallet balance.”). The submit button is enabled only when none of these messages applies. The handler repeats this guard and rejects clicks while busy. The implementation does not impose a decimal-place limit or separately reject a malformed string if JavaScript converts it to a finite number; the wallet integration should define stricter amount parsing and USDC precision before submission. - -The modal's preview shows deposit amount, starting vault balance, projected vault balance, the displayed XLM fee, and total cost. At approval, the amount and starting vault balance are captured so later display and completion use the submitted values. `src/components/DepositPreview.tsx` is a reusable before/after presentation component that also computes a displayed wallet-after value; the active modal renders its own preview markup. Neither preview authorizes a transfer. - -## Retry and reset - -On failure, **Retry deposit** restores the submitted amount in the input, selects Custom, sets the stage to `input`, and shows “Review the transaction details and approve again.” It does not itself submit a transaction or credit the vault. The previous hash and captured values remain in state but the hash card is hidden at `input`; the next approval replaces them with a new mock hash and captured amount. Editing the amount or selecting a preset calls the full reset, which clears the hash, submission, copy state, and scheduled timers. - -After success, **Deposit another amount** calls the full reset with `50` USDC and the matching preset. It keeps the credited vault balance. The demo outcome selector is independent of these resets, so its previous selection persists until changed on the billing page. Closing and reopening resets the flow state but does not undo an already credited demo balance. - -## Current mocks and integration boundary - -- `createMockHash()` builds a 64-character string from the clock and `Math.random()`. It is created **before** the approving stage, rather than returned by a wallet or network submission. `EXPLORER_BASE_URL` points to Stellar testnet, but a generated hash is not evidence of an on-chain transaction. -- Two `window.setTimeout` calls advance the flow after 1.4 and 3.6 seconds. They do not observe wallet approval, transaction submission, confirmation depth, or a backend response. Starting another approval or a full reset clears pending timers. -- `demoOutcome` is a billing-page toggle between `confirmed` and `failed`; it alone decides the final branch. Success mutates only the in-memory vault balance. The displayed wallet balance is not debited, and reload loses the demo state. -- The XLM network fee is a display constant, not an estimated or charged fee. Clipboard copy and the explorer link are UI actions only. +The amount starts at 50 USDC. `MIN_DEPOSIT` is 10 and presets are 10, 50, 100, and +500. Input normalizes separators and limits USDC precision to seven decimal places. +Validation rejects blank, nonnumeric, below-minimum, and above-displayed-wallet +amounts. The service independently validates positive amounts and precision. +The displayed wallet and vault balances remain in-memory UI values; this issue +does not add an authoritative balance lookup. + +Approval reconnects the wallet and checks its current trustline again before +Soroban simulation, protecting against account changes since the initial check. +A newly missing line or unfunded account returns to the corresponding prerequisite +step. An unknown lookup permits simulation. The service prepares the vault's +`deposit(caller, amount)` invocation, signs via Freighter, submits to RPC, and +polls confirmation. The explorer link uses the hash returned by RPC. Only +confirmed deposits update the displayed vault balance. + +Approving, pending, and trustline submission disable amount/preset/Max/Close/Cancel +controls. A definitive deposit failure permits **Retry deposit**, preserving the +amount. Ambiguous submission/confirmation failures require checking the transaction +before another deposit is allowed. **Deposit another amount** resets to 50 USDC. + +## Verification and PR recording (#1316) + +Service and modal tests mock Horizon fetch and Freighter. They cover existing and +missing trustlines, unfunded accounts, network failure, a confirmed trustline, +signature cancellation, invalid issuer configuration, and mainnet without Friendbot. +The service test decodes the exact XDR passed to mocked `signTransaction` and asserts +one `changeTrust` operation with the configured asset. Run: + +```sh +npx tsc -p tsconfig.json --noEmit +npm run build +npx vitest run +npm run test:coverage +``` -For a real integration, the wallet/backend boundary must return or report an authoritative transaction identifier and approval/submission result, then track confirmation or failure for that identifier. Only a confirmed deposit should credit a persisted vault balance; a failed, rejected, or timed-out operation must leave it unchanged. The backend must enforce the minimum, available-funds limit, amount precision, and idempotency independently of the UI. The integration must replace demo timers and `demoOutcome` with observed states, handle wallet rejection and network errors deterministically, prevent duplicate submissions while one is in flight, and restore status after reload. Preserve the user-facing stage names and retry/reset behavior unless a deliberate product change updates this contract. +For the required real-wallet recording: + +1. Configure testnet and the deployed vault; select a fresh Freighter testnet account. + The vault owner must allowlist it if the deployment restricts callers + (`CallerNotInAllowlist`, contract error 44). +2. Open `/billing?deposit=true`. If unfunded, follow Friendbot and return to check again. +3. Record **Add USDC trustline**, its reserve explanation, Freighter's single + changeTrust approval, ledger confirmation, and the resulting deposit approval. +4. With USDC supplied independently, complete the vault deposit and show its explorer + result. USDC issuance is not part of this change. +5. Include a cancellation demonstration if useful. Attach the recording and actual + command output to a PR linking `Closes #1316`. + +Regression proof for row 2: on the original main branch, a wallet without the USDC +trustline can click **Approve Transaction**, reaches simulation, and receives a +generic failure. With this change, the same account is offered the trustline step +before a deposit can be simulated. diff --git a/src/App.deposit.test.tsx b/src/App.deposit.test.tsx index 126ffc8b..b3fdb120 100644 --- a/src/App.deposit.test.tsx +++ b/src/App.deposit.test.tsx @@ -13,7 +13,8 @@ * Run with: npm test -- --run src/App.deposit.test.tsx */ -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; import App from "./App"; @@ -28,9 +29,26 @@ import { normalizeUsdcAmountInput, USDC_DECIMALS } from "./utils/format"; const walletMocks = vi.hoisted(() => ({ isWalletAvailable: vi.fn(), submitVaultDeposit: vi.fn(), + getWalletAddress: vi.fn(), + fetch: vi.fn(), + signTransaction: vi.fn(), + network: "testnet", })); -vi.mock("./services/walletService", () => { +vi.mock("@stellar/freighter-api", () => ({ + isConnected: async () => ({ isConnected: true }), + requestAccess: async () => ({ address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }), + getNetwork: async () => ({ networkPassphrase: "Test SDF Network ; September 2015" }), + signTransaction: walletMocks.signTransaction, +})); + +vi.mock("./config/constants", async (importOriginal) => ({ + ...await importOriginal(), + get STELLAR_NETWORK() { return walletMocks.network; }, +})); + +vi.mock("./services/walletService", async (importOriginal) => { + const actual = await importOriginal(); class WalletServiceError extends Error { readonly code: string; readonly requiresReconciliation: boolean; @@ -44,17 +62,32 @@ vi.mock("./services/walletService", () => { } return { + ...actual, WalletServiceError, isWalletAvailable: walletMocks.isWalletAvailable, submitVaultDeposit: walletMocks.submitVaultDeposit, + getWalletAddress: walletMocks.getWalletAddress, }; }); beforeEach(() => { + // Node's TextEncoder returns a Node-realm Uint8Array. The SDK validates + // these bytes with instanceof, so align jsdom's constructor for real XDR. + vi.stubGlobal("Uint8Array", new TextEncoder().encode("").constructor); + walletMocks.network = "testnet"; + walletMocks.getWalletAddress.mockReset().mockResolvedValue("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + walletMocks.signTransaction.mockReset().mockImplementation(async (xdr: string) => ({ signedTxXdr: xdr, signerAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" })); + vi.stubGlobal("fetch", walletMocks.fetch); + walletMocks.fetch.mockReset().mockResolvedValue({ ok: true, status: 200, json: async () => ({ + sequence: "123", balances: [{ asset_code: "USDC", asset_issuer: "GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ" }], + fee_charged: { mode: "100" }, + }) }); walletMocks.isWalletAvailable.mockReset().mockResolvedValue(true); walletMocks.submitVaultDeposit.mockReset(); }); +afterEach(() => { vi.unstubAllGlobals(); }); + /** Renders the app deep-linked to the billing page with the deposit modal open. */ function renderDepositModal() { return render( @@ -409,3 +442,117 @@ describe("App deposit modal without a wallet", () => { expect(screen.queryByRole("button", { name: "Approve deposit transaction" })).toBeNull(); }); }); + +describe("USDC trustline step (#1316)", () => { + afterEach(() => { cleanup(); }); + + function mockMissingTrustline() { + walletMocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ sequence: "123", balances: [] }) }); + } + + it("replaces Approve with a keyboard reachable trustline action and announces the reserve", async () => { + mockMissingTrustline(); + renderDepositModal(); + const button = await screen.findByRole("button", { name: "Add USDC trustline" }); + expect(screen.queryByRole("button", { name: "Approve deposit transaction" })).toBeNull(); + const status = document.getElementById("usdc-trustline-status"); + expect(status).toHaveAttribute("role", "status"); + expect(status).toHaveTextContent("0.5 XLM base reserve"); + expect(button).toHaveAttribute("aria-describedby", "usdc-trustline-status"); + const user = userEvent.setup(); + for (let tabs = 0; tabs < 40 && document.activeElement !== button; tabs++) await user.tab(); + expect(button).toHaveFocus(); + expect(walletMocks.submitVaultDeposit).not.toHaveBeenCalled(); + }); + + it("allows Approve with a warning when the Horizon check fails", async () => { + walletMocks.fetch.mockRejectedValue(new TypeError("Failed to fetch")); + renderDepositModal(); + expect(await screen.findByRole("button", { name: "Approve deposit transaction" })).toBeEnabled(); + expect(screen.getByText(/Could not check your USDC trustline through Horizon/)).toBeVisible(); + }); + + it.each(["testnet", "mainnet"])("shows Friendbot only on testnet (%s configuration)", async (network) => { + walletMocks.network = network; + walletMocks.fetch.mockResolvedValue({ ok: false, status: 404 }); + renderDepositModal(); + await screen.findByText(/This Stellar account is not funded/); + const friendbot = screen.queryByRole("link", { name: /Fund testnet account with Friendbot/ }); + if (network === "testnet") expect(friendbot).toHaveAttribute("href", "https://friendbot.stellar.org/?addr=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + else expect(friendbot).toBeNull(); + expect(screen.queryByRole("button", { name: "Approve deposit transaction" })).toBeNull(); + mockMissingTrustline(); + fireEvent.click(screen.getByRole("button", { name: "Check USDC trustline again" })); + expect(await screen.findByRole("button", { name: "Add USDC trustline" })).toBeEnabled(); + }); + + it("adds a trustline, waits for confirmation, rechecks, then permits a deposit", async () => { + let confirmed = false; + walletMocks.fetch.mockImplementation(async (_url: string, options?: RequestInit) => { + if (options?.method === "POST") { + confirmed = true; + return { ok: true, json: async () => ({ successful: true }) }; + } + return { ok: true, json: async () => ({ sequence: "123", balances: confirmed ? [ + { asset_code: "USDC", asset_issuer: "GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ" }, + ] : [] }) }; + }); + renderDepositModal(); + const add = await screen.findByRole("button", { name: "Add USDC trustline" }); + const user = userEvent.setup(); + add.focus(); + await user.keyboard("{Enter}"); + const approve = await screen.findByRole("button", { name: "Approve deposit transaction" }); + expect(approve).toBeEnabled(); + expect(screen.getByText(/USDC trustline ready/)).toBeVisible(); + expect(walletMocks.signTransaction).toHaveBeenCalledOnce(); + walletMocks.submitVaultDeposit.mockResolvedValue({ hash: "A".repeat(64) }); + fireEvent.click(approve); + await screen.findByText("Your updated vault balance is 334.62 USDC and ready for usage."); + }); + + it("returns to the trustline step without an error when a signature is rejected", async () => { + mockMissingTrustline(); + walletMocks.signTransaction.mockResolvedValue({ error: { code: 4001, message: "User rejected signature" } }); + renderDepositModal(); + fireEvent.click(await screen.findByRole("button", { name: "Add USDC trustline" })); + await screen.findByText(/Trustline signature cancelled/); + expect(screen.getByRole("button", { name: "Add USDC trustline" })).toBeEnabled(); + expect(screen.queryByText("Transaction failed")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("keeps the modal busy until Horizon responds and supports retry after a submission failure", async () => { + mockMissingTrustline(); + let reject!: (error: Error) => void; + const accountResponse = { ok: true, json: async () => ({ sequence: "123", balances: [] }) }; + walletMocks.fetch.mockImplementation(async (_url: string, options?: RequestInit) => options?.method === "POST" + ? new Promise((_resolve, rejectPromise) => { reject = rejectPromise; }) : accountResponse); + renderDepositModal(); + fireEvent.click(await screen.findByRole("button", { name: "Add USDC trustline" })); + await waitFor(() => expect(reject).toBeDefined()); + expect(screen.getByRole("button", { name: "Adding USDC trustline..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await act(async () => { reject(new Error("connection lost")); }); + expect(screen.getByRole("button", { name: "Add USDC trustline" })).toBeEnabled(); + expect(screen.getByText(/Could not confirm the USDC trustline/)).toBeVisible(); + }); + + it("returns to the appropriate step if the wallet changes before deposit simulation", async () => { + const { WalletServiceError } = await import("./services/walletService"); + walletMocks.submitVaultDeposit.mockRejectedValue(new WalletServiceError("TRUSTLINE_MISSING", "Add trustline")); + renderDepositModal(); + const approve = await screen.findByRole("button", { name: "Approve deposit transaction" }); + mockMissingTrustline(); + fireEvent.click(approve); + expect(await screen.findByRole("button", { name: "Add USDC trustline" })).toBeEnabled(); + expect(screen.queryByText("Transaction failed")).toBeNull(); + }); + + it("offers reconnection without an error when wallet access is cancelled", async () => { + walletMocks.getWalletAddress.mockRejectedValueOnce({ code: "SIGNATURE_REJECTED" }); + renderDepositModal(); + fireEvent.click(await screen.findByRole("button", { name: "Connect Freighter" })); + expect(await screen.findByRole("button", { name: "Approve deposit transaction" })).toBeEnabled(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 16123691..6e6f2081 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,8 +7,8 @@ import NotFound from "./components/NotFound"; import { startRouteLoading, stopRouteLoading } from "./hooks/useRouteLoading"; import { formatUsdc, formatUsdShortcut, normalizeUsdcAmountInput, 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 } from "./config/constants"; -import type { WalletServiceErrorCode } from "./services/walletService"; +import { ENABLE_DEMO_OUTCOME, EXPLORER_BASE_URL, MIN_DEPOSIT, NETWORK_FEE, PRESET_AMOUNTS, EXTERNAL_LINKS, TESTNET_DEPLOYMENT, testnetContractUrl, STELLAR_NETWORK, STELLAR_USDC_CONFIGURATION_ERROR } from "./config/constants"; +import type { WalletServiceErrorCode, UsdcTrustlineStatus } from "./services/walletService"; import CompareDrawer from "./components/CompareDrawer"; import CompareTray from "./components/CompareTray"; import ExternalLink from "./components/ExternalLink"; @@ -404,6 +404,11 @@ function App() { const [depositStage, setDepositStage] = useState("input"); const [demoOutcome, setDemoOutcome] = useState("confirmed"); const [walletAvailability, setWalletAvailability] = useState<"checking" | "available" | "missing">("checking"); + const [trustlineStatus, setTrustlineStatus] = useState("checking"); + const [trustlineMessage, setTrustlineMessage] = useState(""); + const [walletAddress, setWalletAddress] = useState(""); + const [isAddingTrustline, setIsAddingTrustline] = useState(false); + const [trustlineCheckVersion, setTrustlineCheckVersion] = useState(0); const [depositFailureCode, setDepositFailureCode] = useState(null); const [depositRequiresReconciliation, setDepositRequiresReconciliation] = useState(false); const [txHash, setTxHash] = useState(""); @@ -418,7 +423,7 @@ function App() { const activeAmount = submittedAmount ?? (hasAmount ? parsedAmount : 0); const previewCurrentBalance = submittedStartingBalance ?? vaultBalance; const projectedBalance = previewCurrentBalance + activeAmount; - const isBusy = depositStage === "approving" || depositStage === "pending"; + const isBusy = isAddingTrustline || depositStage === "approving" || depositStage === "pending"; const hasUnconfirmedDeposit = depositRequiresReconciliation; const { networkFee, isEstimated: isNetworkFeeEstimated } = useNetworkFee(NETWORK_FEE, isDepositOpen); const balanceDelta = formatUsdc(submittedAmount ?? (hasAmount ? parsedAmount : 0)); @@ -444,19 +449,31 @@ function App() { let isCurrent = true; setWalletAvailability("checking"); + setTrustlineStatus("checking"); + setTrustlineMessage(""); import("./services/walletService") - .then(({ isWalletAvailable }) => isWalletAvailable()) - .then((available) => { - if (isCurrent) setWalletAvailability(available ? "available" : "missing"); + .then(async ({ isWalletAvailable, getWalletAddress, hasUsdcTrustline }) => { + const available = await isWalletAvailable(); + if (!isCurrent) return; + setWalletAvailability(available ? "available" : "missing"); + if (!available) return; + if (STELLAR_USDC_CONFIGURATION_ERROR) throw new Error(STELLAR_USDC_CONFIGURATION_ERROR); + const address = await getWalletAddress(); + if (!isCurrent) return; + setWalletAddress(address); + const status = await hasUsdcTrustline(address); + if (isCurrent) setTrustlineStatus(status); }) - .catch(() => { - if (isCurrent) setWalletAvailability("missing"); + .catch((error) => { + if (!isCurrent) return; + setTrustlineStatus(error.code === "SIGNATURE_REJECTED" ? "connect" : "error"); + setTrustlineMessage(error.code === "SIGNATURE_REJECTED" ? "Wallet connection cancelled. Connect Freighter when you are ready." : error.message); }); return () => { isCurrent = false; }; - }, [isDepositOpen]); + }, [isDepositOpen, trustlineCheckVersion]); useEffect(() => { if (location.pathname !== APP_ROUTES.billing && isDepositOpen) { @@ -546,7 +563,7 @@ function App() { }; const handleApproveTransaction = async () => { - if (!hasValidAmount || isBusy || hasUnconfirmedDeposit || walletAvailability !== "available") return; + if (!hasValidAmount || isBusy || hasUnconfirmedDeposit || walletAvailability !== "available" || (trustlineStatus !== "present" && trustlineStatus !== "unknown")) return; const approvedAmount = parsedAmount; const startingBalance = vaultBalance; @@ -581,6 +598,15 @@ function App() { } catch (error) { const walletError = isWalletServiceError?.(error) ? error : null; const failureCode = walletError?.code ?? "NETWORK_ERROR"; + if (failureCode === "TRUSTLINE_MISSING" || failureCode === "UNFUNDED_ACCOUNT") { + setTrustlineStatus(failureCode === "TRUSTLINE_MISSING" ? "missing" : "unfunded-account"); + setTrustlineCheckVersion((version) => version + 1); + setDepositStage("input"); + setSubmittedAmount(null); + setSubmittedStartingBalance(null); + setStatusMessage("Complete the wallet setup below before depositing."); + return; + } setDepositFailureCode(failureCode); setDepositRequiresReconciliation(walletError?.requiresReconciliation ?? false); setDepositStage("failed"); @@ -592,6 +618,28 @@ function App() { } }; + const handleAddTrustline = async () => { + if (isBusy || trustlineStatus !== "missing") return; + setIsAddingTrustline(true); + setTrustlineMessage("Approve the USDC trustline in Freighter. Waiting for Stellar confirmation."); + try { + const { addUsdcTrustline } = await import("./services/walletService"); + const result = await addUsdcTrustline(); + if (result === "cancelled") { + setTrustlineMessage("Trustline signature cancelled. You can add the USDC trustline when you are ready."); + } else { + // Reconnect and recheck in case the selected wallet changed while signing. + setTrustlineCheckVersion((version) => version + 1); + } + } catch (error) { + const failure = error as WalletServiceFailure; + if (failure.code === "UNFUNDED_ACCOUNT") setTrustlineCheckVersion((version) => version + 1); + setTrustlineMessage(failure.message || "Could not add the USDC trustline. Check your wallet and try again."); + } finally { + setIsAddingTrustline(false); + } + }; + const handleRetry = () => { if (hasUnconfirmedDeposit) return; if (submittedAmount !== null) { @@ -894,6 +942,31 @@ function App() { {STAGE_LABELS[depositStage]} + {walletAvailability === "available" && depositStage === "input" && ( +
+ {trustlineStatus === "checking" &&

Checking your USDC trustline...

} + {trustlineStatus === "present" &&

USDC trustline ready. Review your deposit and approve when ready.

} + {trustlineStatus === "missing" && ( + <> + Add a USDC trustline before depositing +

A trustline lets your wallet hold and send this vault’s USDC asset. Adding it increases your minimum balance by a 0.5 XLM base reserve, plus a small transaction fee. This step only adds the trustline; it does not deposit or supply USDC.

+ + )} + {trustlineStatus === "unfunded-account" && ( + <> +

This Stellar account is not funded. Fund it with XLM, then check again before adding the USDC trustline.

+ {STELLAR_NETWORK === "testnet" && ( + + Fund testnet account with Friendbot + + )} + + )} + {trustlineStatus === "unknown" &&

Could not check your USDC trustline through Horizon. You can still approve, but a missing trustline may cause the deposit to fail.

} + {trustlineMessage &&

{trustlineMessage}

} +
+ )} +
@@ -1059,12 +1132,28 @@ function App() { + ) : trustlineStatus === "checking" ? ( + + ) : trustlineStatus === "missing" ? ( + + ) : trustlineStatus === "unfunded-account" || trustlineStatus === "error" || trustlineStatus === "connect" ? ( + ) : ( )} + {trustlineStatus === "missing" && trustlineMessage && !isBusy && depositStage === "input" && ( + + )} + diff --git a/src/config/constants.test.ts b/src/config/constants.test.ts new file mode 100644 index 00000000..7411efdd --- /dev/null +++ b/src/config/constants.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); }); + +describe("USDC asset configuration", () => { + it("defaults to the deployed testnet asset", async () => { + vi.stubEnv("VITE_STELLAR_NETWORK", "testnet"); + vi.stubEnv("VITE_STELLAR_USDC_ISSUER", undefined); + delete import.meta.env.VITE_STELLAR_USDC_ISSUER; + delete process.env.VITE_STELLAR_USDC_ISSUER; + vi.resetModules(); + const config = await import("./constants"); + expect(config.STELLAR_USDC_ASSET).toEqual({ code: "USDC", issuer: "GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ" }); + expect(config.STELLAR_USDC_CONFIGURATION_ERROR).toBeNull(); + }); + + it("rejects an invalid issuer env before making any Horizon call", async () => { + vi.stubEnv("VITE_STELLAR_USDC_ISSUER", "invalid-key"); + vi.resetModules(); + const fetchMock = vi.spyOn(globalThis, "fetch"); + try { + const { hasUsdcTrustline, addUsdcTrustline } = await import("../services/walletService"); + await expect(hasUsdcTrustline("GACCOUNT")).rejects.toMatchObject({ code: "CONFIGURATION" }); + await expect(addUsdcTrustline()).rejects.toMatchObject({ code: "CONFIGURATION" }); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { fetchMock.mockRestore(); } + }); + + it.each(["mainnet", "futurenet"])("requires an explicit issuer on %s and honors a valid override", async (network) => { + vi.stubEnv("VITE_STELLAR_NETWORK", network); + vi.stubEnv("VITE_STELLAR_USDC_ISSUER", undefined); + delete import.meta.env.VITE_STELLAR_USDC_ISSUER; + delete process.env.VITE_STELLAR_USDC_ISSUER; + vi.resetModules(); + expect((await import("./constants")).STELLAR_USDC_CONFIGURATION_ERROR).toMatch(/valid Stellar Ed25519/); + vi.stubEnv("VITE_STELLAR_USDC_ISSUER", " GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF "); + vi.resetModules(); + const config = await import("./constants"); + expect(config.STELLAR_USDC_ASSET.issuer).toBe("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + expect(config.STELLAR_USDC_CONFIGURATION_ERROR).toBeNull(); + expect(config.STELLAR_HORIZON_URL).toBe(network === "mainnet" ? "https://horizon.stellar.org" : "https://horizon-futurenet.stellar.org"); + }); +}); diff --git a/src/config/constants.ts b/src/config/constants.ts index cbedc350..9a55392c 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,3 +1,5 @@ +import { StrKey } from "@stellar/stellar-sdk"; + /** * Shared application constants. * @@ -91,8 +93,7 @@ type RuntimeEnv = Record & { DEV?: boolean; }; -const runtimeEnv: RuntimeEnv = - (import.meta as { env?: RuntimeEnv }).env ?? {}; +const runtimeEnv: RuntimeEnv = import.meta.env; /** Trim whitespace and any trailing slashes from a configured origin. */ export function normalizeBaseUrl(value: string | undefined): string { @@ -170,6 +171,31 @@ export const STELLAR_VAULT_CONTRACT_ID = String( export const STELLAR_USDC_DECIMALS = 7; +/** Classic asset backing the vault's USDC Stellar Asset Contract. */ +export const STELLAR_USDC_ASSET = { + code: "USDC", + issuer: String( + runtimeEnv.VITE_STELLAR_USDC_ISSUER ?? + (STELLAR_NETWORK === "testnet" + ? "GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ" + : ""), + ).trim(), +} as const; + +// Keep the app usable with bad configuration; wallet actions surface this +// error before making any Horizon request or asking for a signature. +export const STELLAR_USDC_CONFIGURATION_ERROR = + StrKey.isValidEd25519PublicKey(STELLAR_USDC_ASSET.issuer) + ? null + : "Configure VITE_STELLAR_USDC_ISSUER with a valid Stellar Ed25519 public key for this network."; + +/** Horizon must follow the same network as the wallet and Soroban RPC. */ +export const STELLAR_HORIZON_URL = { + testnet: "https://horizon-testnet.stellar.org", + mainnet: "https://horizon.stellar.org", + futurenet: "https://horizon-futurenet.stellar.org", +}[STELLAR_NETWORK]; + /** * Reference deployment of the Callora contracts on Stellar testnet. Shown in * the footer and on the documentation page as read-only explorer links; it does diff --git a/src/services/walletService.test.ts b/src/services/walletService.test.ts index 55fa809f..2b32aa13 100644 --- a/src/services/walletService.test.ts +++ b/src/services/walletService.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({ requestAccess: vi.fn(), getNetwork: vi.fn(), signTransaction: vi.fn(), + fetch: vi.fn(), + configurationError: null as string | null, serverConstructor: vi.fn(), server: { getAccount: vi.fn(), @@ -14,8 +16,6 @@ const mocks = vi.hoisted(() => ({ sendTransaction: vi.fn(), getTransaction: vi.fn(), }, - transactionBuilder: vi.fn(), - operation: undefined as unknown, })); vi.mock("@stellar/freighter-api", () => ({ @@ -25,79 +25,30 @@ vi.mock("@stellar/freighter-api", () => ({ signTransaction: mocks.signTransaction, })); -vi.mock("@stellar/stellar-sdk", () => { - class Address { - toScVal() { - return "address-scval"; - } - } - - class Contract { - call(method: string, ...args: unknown[]) { - return { method, args }; - } - } - - class TransactionBuilder { - constructor() { - mocks.transactionBuilder(); - } - addOperation(operation: unknown) { - mocks.operation = operation; - return this; - } - setTimeout() { - return this; - } - build() { - return { toXDR: () => "unsigned-xdr" }; - } - } - - class Transaction { - constructor(public xdr: string, public passphrase: string) {} - } - - class Server { +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rpc: { ...actual.rpc, Server: class { constructor(...args: unknown[]) { mocks.serverConstructor(...args); return mocks.server; } - } - - return { - Address, - BASE_FEE: "100", - Contract, - Networks: { - PUBLIC: "Public Global Stellar Network ; September 2015", - TESTNET: "Test SDF Network ; September 2015", - }, - Transaction, - TransactionBuilder, - nativeToScVal: (value: unknown, options: unknown) => ({ value, options }), - rpc: { - Server, - Api: { - GetTransactionStatus: { - SUCCESS: "SUCCESS", - FAILED: "FAILED", - NOT_FOUND: "NOT_FOUND", - }, - }, - }, - }; + } } }; }); - -vi.mock("../config/constants", () => ({ +vi.mock("../config/constants", async (importOriginal) => ({ + ...await importOriginal(), STELLAR_NETWORK: "testnet", STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015", STELLAR_RPC_URL: "https://soroban-testnet.stellar.org", STELLAR_USDC_DECIMALS: 7, - STELLAR_VAULT_CONTRACT_ID: "CVAULTTESTCONTRACTID", + STELLAR_VAULT_CONTRACT_ID: "CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ", + get STELLAR_USDC_CONFIGURATION_ERROR() { return mocks.configurationError; }, })); +import { Account, Asset, Transaction } from "@stellar/stellar-sdk"; +import { STELLAR_USDC_ASSET, STELLAR_NETWORK_PASSPHRASE } from "../config/constants"; +const walletAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; import { + hasUsdcTrustline, addUsdcTrustline, getWalletAddress, isWalletAvailable, submitVaultDeposit, } from "./walletService"; @@ -105,20 +56,20 @@ import { describe("walletService", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.operation = undefined; + mocks.configurationError = null; + vi.stubGlobal("fetch", mocks.fetch); + mocks.fetch.mockReset().mockResolvedValue({ + ok: true, status: 200, + json: async () => ({ sequence: "123", balances: [{ asset_code: "USDC", asset_issuer: STELLAR_USDC_ASSET.issuer }] }), + }); mocks.isConnected.mockResolvedValue({ isConnected: true }); - mocks.requestAccess.mockResolvedValue({ address: "GUSERACCOUNT" }); + mocks.requestAccess.mockResolvedValue({ address: walletAddress }); mocks.getNetwork.mockResolvedValue({ networkPassphrase: "Test SDF Network ; September 2015", }); - mocks.signTransaction.mockResolvedValue({ - signedTxXdr: "signed-xdr", - signerAddress: "GUSERACCOUNT", - }); - mocks.server.getAccount.mockResolvedValue({ accountId: "GUSERACCOUNT" }); - mocks.server.prepareTransaction.mockResolvedValue({ - toXDR: () => "prepared-xdr", - }); + mocks.signTransaction.mockImplementation(async (xdr: string) => ({ signedTxXdr: xdr, signerAddress: walletAddress })); + mocks.server.getAccount.mockImplementation(async () => new Account(walletAddress, "123")); + mocks.server.prepareTransaction.mockImplementation(async (transaction: Transaction) => transaction); mocks.server.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "HASH_RETURNED_BY_STELLAR", @@ -128,6 +79,7 @@ describe("walletService", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("detects when Freighter is missing", async () => { @@ -145,8 +97,8 @@ describe("walletService", () => { expect(onSubmitted).toHaveBeenCalledWith("HASH_RETURNED_BY_STELLAR"); expect(mocks.signTransaction).toHaveBeenCalledWith( - "prepared-xdr", - expect.objectContaining({ address: "GUSERACCOUNT" }), + expect.any(String), + expect.objectContaining({ address: walletAddress }), ); expect(mocks.server.sendTransaction).toHaveBeenCalledTimes(1); expect(mocks.server.getTransaction).toHaveBeenCalledWith( @@ -216,4 +168,175 @@ describe("walletService", () => { }); expect(mocks.requestAccess).not.toHaveBeenCalled(); }); + + it("matches both the code and issuer, including a zero-balance trustline", async () => { + expect(await hasUsdcTrustline(walletAddress)).toBe("present"); + expect(mocks.fetch).toHaveBeenCalledWith( + `https://horizon-testnet.stellar.org/accounts/${walletAddress}`, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + mocks.fetch.mockResolvedValue({ ok: true, json: async () => ({ balances: [ + { asset_type: "native" }, null, + { asset_code: "USDC", asset_issuer: walletAddress }, + { asset_code: "OTHER", asset_issuer: STELLAR_USDC_ASSET.issuer }, + ] }) }); + expect(await hasUsdcTrustline(walletAddress)).toBe("missing"); + }); + + it("distinguishes an unfunded account from HTTP errors and malformed responses", async () => { + mocks.fetch.mockResolvedValueOnce({ ok: false, status: 404 }); + expect(await hasUsdcTrustline(walletAddress)).toBe("unfunded-account"); + mocks.fetch.mockResolvedValueOnce({ ok: false, status: 503 }); + expect(await hasUsdcTrustline(walletAddress)).toBe("unknown"); + mocks.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + expect(await hasUsdcTrustline(walletAddress)).toBe("unknown"); + mocks.fetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); + expect(await hasUsdcTrustline(walletAddress)).toBe("unknown"); + }); + + it("aborts a stalled Horizon lookup and reports unknown", async () => { + vi.useFakeTimers(); + try { + mocks.fetch.mockImplementationOnce((_url: string, { signal }: RequestInit) => new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(new Error("aborted"))); + })); + const check = hasUsdcTrustline(walletAddress); + await vi.advanceTimersByTimeAsync(8_000); + expect(await check).toBe("unknown"); + } finally { vi.useRealTimers(); } + }); + + it("rejects invalid issuer configuration before Horizon or Freighter calls", async () => { + mocks.configurationError = "Configure VITE_STELLAR_USDC_ISSUER with a valid Stellar Ed25519 public key for this network."; + await expect(hasUsdcTrustline(walletAddress)).rejects.toMatchObject({ code: "CONFIGURATION" }); + await expect(addUsdcTrustline()).rejects.toMatchObject({ code: "CONFIGURATION" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.requestAccess).not.toHaveBeenCalled(); + }); + + it("signs exactly one changeTrust for the configured asset and waits for Horizon success", async () => { + let confirm!: (value: unknown) => void; + mocks.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: "123" }) }) + .mockImplementationOnce(() => new Promise((resolve) => { confirm = resolve; })); + let completed = false; + const add = addUsdcTrustline().then((result) => { completed = true; return result; }); + await vi.waitFor(() => expect(mocks.signTransaction).toHaveBeenCalledOnce()); + const [xdr, options] = mocks.signTransaction.mock.calls[0]; + const transaction = new Transaction(xdr, STELLAR_NETWORK_PASSPHRASE); + expect(transaction.source).toBe(walletAddress); + expect(transaction.sequence).toBe("124"); + expect(transaction.operations).toHaveLength(1); + const operation = transaction.operations[0]; + expect(operation.type).toBe("changeTrust"); + if (operation.type !== "changeTrust") throw new Error("Expected changeTrust"); + if (!(operation.line instanceof Asset)) throw new Error("Expected a classic asset"); + expect(operation.line.getCode()).toBe(STELLAR_USDC_ASSET.code); + expect(operation.line.getIssuer()).toBe(STELLAR_USDC_ASSET.issuer); + expect(options).toEqual({ address: walletAddress, networkPassphrase: STELLAR_NETWORK_PASSPHRASE }); + expect(mocks.fetch).toHaveBeenLastCalledWith("https://horizon-testnet.stellar.org/transactions", expect.objectContaining({ + method: "POST", body: new URLSearchParams({ tx: xdr }).toString(), + })); + expect(completed).toBe(false); + confirm({ ok: true, json: async () => ({ successful: true }) }); + expect(await add).toBe("present"); + expect(await hasUsdcTrustline(walletAddress)).toBe("present"); + }); + + it.each(["returned", "thrown"])("treats a %s signature rejection as cancellation", async (kind) => { + const rejection = { code: 4001, message: "User declined" }; + if (kind === "returned") mocks.signTransaction.mockResolvedValueOnce({ error: rejection }); + else mocks.signTransaction.mockRejectedValueOnce(rejection); + expect(await addUsdcTrustline()).toBe("cancelled"); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + }); + + it("treats rejected wallet access as cancellation", async () => { + mocks.requestAccess.mockRejectedValueOnce({ code: 4001 }); + expect(await addUsdcTrustline()).toBe("cancelled"); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("does not submit an empty signature or one from a different account", async () => { + mocks.signTransaction.mockResolvedValueOnce({ signedTxXdr: "" }); + await expect(addUsdcTrustline()).rejects.toMatchObject({ code: "WALLET_ERROR" }); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + }); + + it.each([404, 503])("handles Horizon account status %s before signing", async (status) => { + mocks.fetch.mockResolvedValueOnce({ ok: false, status }); + await expect(addUsdcTrustline()).rejects.toMatchObject({ code: status === 404 ? "UNFUNDED_ACCOUNT" : "NETWORK_ERROR" }); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + }); + + it.each([false, true])("reports a failed trustline transaction (HTTP success: %s)", async (ok) => { + mocks.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: "123" }) }) + .mockResolvedValueOnce({ ok, json: async () => ({ successful: false }) }); + await expect(addUsdcTrustline()).rejects.toMatchObject({ code: "TRANSACTION_FAILED" }); + }); + + it("reports a lost submission response without claiming the trustline is present", async () => { + mocks.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: "123" }) }) + .mockRejectedValueOnce(new Error("network lost")); + await expect(addUsdcTrustline()).rejects.toMatchObject({ code: "NETWORK_ERROR" }); + }); + + it("checks the selected account again before Soroban simulation", async () => { + mocks.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ balances: [] }) }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "TRUSTLINE_MISSING" }); + expect(mocks.server.prepareTransaction).not.toHaveBeenCalled(); + mocks.fetch.mockResolvedValueOnce({ ok: false, status: 404 }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "UNFUNDED_ACCOUNT" }); + }); + + it("allows deposit simulation when Horizon cannot check the trustline", async () => { + mocks.fetch.mockRejectedValueOnce(new Error("offline")); + await expect(submitVaultDeposit("50")).resolves.toHaveProperty("hash"); + }); + + it("handles missing addresses, network errors, and a missing extension", async () => { + mocks.requestAccess.mockResolvedValueOnce({ address: "" }); + await expect(getWalletAddress()).rejects.toMatchObject({ code: "WALLET_UNAVAILABLE" }); + mocks.getNetwork.mockResolvedValueOnce({ error: { message: "wallet error" } }); + await expect(getWalletAddress()).rejects.toMatchObject({ code: "WALLET_ERROR" }); + mocks.isConnected.mockRejectedValueOnce(new Error("not installed")); + await expect(getWalletAddress()).rejects.toMatchObject({ code: "WALLET_UNAVAILABLE" }); + }); + + it.each(["timeout", "connection lost"])("preserves pre-submission RPC error handling (%s)", async (message) => { + mocks.server.getAccount.mockRejectedValueOnce(new Error(message)); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "NETWORK_ERROR", requiresReconciliation: false }); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + }); + + it("does not request a deposit signature if simulation fails", async () => { + mocks.server.prepareTransaction.mockRejectedValueOnce(new Error("simulation failed")); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "NETWORK_ERROR" }); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + }); + + it("does not submit a deposit signed by another account", async () => { + mocks.signTransaction.mockResolvedValueOnce({ signedTxXdr: "xdr", signerAddress: STELLAR_USDC_ASSET.issuer }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "WALLET_ERROR" }); + expect(mocks.server.sendTransaction).not.toHaveBeenCalled(); + }); + + it.each(["ERROR", "TRY_AGAIN_LATER"])("preserves definitive RPC submission failures (%s)", async (status) => { + mocks.server.sendTransaction.mockResolvedValueOnce({ status }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "TRANSACTION_FAILED", requiresReconciliation: false }); + }); + + it.each(["timeout", "connection lost"])("requires reconciliation after a lost RPC response (%s)", async (message) => { + mocks.server.sendTransaction.mockRejectedValueOnce(new Error(message)); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "NETWORK_TIMEOUT", requiresReconciliation: true }); + }); + + it("requires reconciliation after losing confirmation access", async () => { + mocks.server.getTransaction.mockRejectedValueOnce(new Error("offline")); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "NETWORK_TIMEOUT", requiresReconciliation: true }); + }); + + it("reports a definitive failed deposit without requiring reconciliation", async () => { + mocks.server.getTransaction.mockResolvedValueOnce({ status: "FAILED" }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "TRANSACTION_FAILED", requiresReconciliation: false }); + }); }); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 6fa67a4e..cd1f0754 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -6,8 +6,11 @@ import { } from "@stellar/freighter-api"; import { Address, + Account, + Asset, BASE_FEE, Contract, + Operation, Transaction, TransactionBuilder, nativeToScVal, @@ -15,9 +18,12 @@ import { } from "@stellar/stellar-sdk"; import { STELLAR_NETWORK, + STELLAR_HORIZON_URL, STELLAR_NETWORK_PASSPHRASE, STELLAR_RPC_URL, STELLAR_USDC_DECIMALS, + STELLAR_USDC_ASSET, + STELLAR_USDC_CONFIGURATION_ERROR, STELLAR_VAULT_CONTRACT_ID, type StellarNetwork, } from "../config/constants"; @@ -40,6 +46,8 @@ export type WalletServiceErrorCode = | "NETWORK_MISMATCH" | "CONFIGURATION" | "INVALID_AMOUNT" + | "TRUSTLINE_MISSING" + | "UNFUNDED_ACCOUNT" | "TRANSACTION_FAILED" | "WALLET_ERROR" | "NETWORK_ERROR"; @@ -139,6 +147,117 @@ export async function isWalletAvailable(): Promise { } } +export type UsdcTrustlineStatus = "present" | "missing" | "unfunded-account" | "unknown"; + +function validateUsdcConfiguration(): void { + if (STELLAR_USDC_CONFIGURATION_ERROR) { + throw new WalletServiceError("CONFIGURATION", STELLAR_USDC_CONFIGURATION_ERROR); + } +} + +async function horizonRequest(path: string, options?: RequestInit, timeoutMs = RPC_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(`${STELLAR_HORIZON_URL}${path}`, { + ...options, + signal: controller.signal, + }); + } finally { + globalThis.clearTimeout(timeout); + } +} + +/** A failed lookup is not evidence that an account lacks a trustline. */ +export async function hasUsdcTrustline(address: string): Promise { + validateUsdcConfiguration(); + try { + const response = await horizonRequest(`/accounts/${encodeURIComponent(address)}`); + if (response.status === 404) return "unfunded-account"; + if (!response.ok) return "unknown"; + const account = await response.json(); + if (!Array.isArray(account?.balances)) return "unknown"; + return account.balances.some((balance: { asset_code?: string; asset_issuer?: string } | null) => + balance?.asset_code === STELLAR_USDC_ASSET.code && + balance?.asset_issuer === STELLAR_USDC_ASSET.issuer, + ) ? "present" : "missing"; + } catch { + return "unknown"; + } +} + +/** Connect Freighter and ensure every action targets the configured network. */ +export async function getWalletAddress(): Promise { + if (!(await isWalletAvailable())) { + throw new WalletServiceError("WALLET_UNAVAILABLE", "Freighter is not installed or available in this browser."); + } + try { + const access = await requestAccess(); + if (access.error) throw access.error; + if (!access.address) { + throw new WalletServiceError("WALLET_UNAVAILABLE", "Freighter did not provide an account address. Connect an account and try again."); + } + const network = await getNetwork(); + if (network.error) throw network.error; + if (network.networkPassphrase !== STELLAR_NETWORK_PASSPHRASE) { + throw new WalletServiceError("NETWORK_MISMATCH", `Switch Freighter to Stellar ${STELLAR_NETWORK_LABELS[STELLAR_NETWORK]} and try again.`); + } + return access.address; + } catch (error) { + if (error instanceof WalletServiceError) throw error; + throwWalletError(error); + } +} + +/** Horizon's synchronous submission waits until the transaction reaches a ledger. */ +export async function addUsdcTrustline(): Promise<"present" | "cancelled"> { + validateUsdcConfiguration(); + try { + const address = await getWalletAddress(); + const response = await horizonRequest(`/accounts/${encodeURIComponent(address)}`); + if (response.status === 404) { + throw new WalletServiceError("UNFUNDED_ACCOUNT", "Fund this Stellar account with XLM before adding a USDC trustline."); + } + if (!response.ok) throw new Error("Could not load the Stellar account."); + const account = await response.json(); + const transaction = new TransactionBuilder(new Account(address, account.sequence), { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation(Operation.changeTrust({ + asset: new Asset(STELLAR_USDC_ASSET.code, STELLAR_USDC_ASSET.issuer), + })) + .setTimeout(TRANSACTION_TIMEOUT_SECONDS) + .build(); + + const signature = await signTransaction(transaction.toXDR(), { + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + address, + }); + if (signature.error) throw signature.error; + if (!signature.signedTxXdr || signature.signerAddress !== address) { + throw new WalletServiceError("WALLET_ERROR", "Freighter did not sign the trustline with the connected account."); + } + const submission = await horizonRequest("/transactions", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ tx: signature.signedTxXdr }).toString(), + }, CONFIRMATION_TIMEOUT_MS); + if (!submission.ok) { + throw new WalletServiceError("TRANSACTION_FAILED", "Stellar could not add the USDC trustline. Check that your account has enough XLM for the 0.5 XLM reserve and transaction fee, then retry."); + } + const result = await submission.json(); + if (result.successful !== true) { + throw new WalletServiceError("TRANSACTION_FAILED", "Stellar did not confirm the USDC trustline. Check your wallet activity, then check the trustline again."); + } + return "present"; + } catch (error) { + if (isUserRejection(error) || (error instanceof WalletServiceError && error.code === "SIGNATURE_REJECTED")) return "cancelled"; + if (error instanceof WalletServiceError) throw error; + throw new WalletServiceError("NETWORK_ERROR", "Could not confirm the USDC trustline. Check your connection and wallet activity, then check the trustline again."); + } +} + function createRpcServer(): rpc.Server { if (!STELLAR_RPC_URL) { throw new WalletServiceError( @@ -210,7 +329,7 @@ async function waitForConfirmation( const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; await new Promise((resolve) => - window.setTimeout(resolve, Math.min(CONFIRMATION_POLL_INTERVAL_MS, remainingMs)), + globalThis.setTimeout(resolve, Math.min(CONFIRMATION_POLL_INTERVAL_MS, remainingMs)), ); } @@ -232,52 +351,21 @@ export async function submitVaultDeposit( const amountStroops = toUsdcStroops(amount); validateConfiguration(); - let connection; - try { - connection = await isConnected(); - } catch { - connection = { isConnected: false }; - } - - if (!connection.isConnected) { - throw new WalletServiceError( - "WALLET_UNAVAILABLE", - "Freighter is not installed or available in this browser.", - ); - } - - let access; - try { - access = await requestAccess(); - } catch (error) { - throwWalletError(error); - } - if (access.error) throwWalletError(access.error); - if (!access.address) { - throw new WalletServiceError( - "WALLET_UNAVAILABLE", - "Freighter did not provide an account address. Connect an account and try again.", - ); - } - - let walletNetwork; - try { - walletNetwork = await getNetwork(); - } catch (error) { - throwWalletError(error); - } - if (walletNetwork.error) throwWalletError(walletNetwork.error); - if (walletNetwork.networkPassphrase !== STELLAR_NETWORK_PASSPHRASE) { + validateUsdcConfiguration(); + const address = await getWalletAddress(); + const trustline = await hasUsdcTrustline(address); + if (trustline === "missing" || trustline === "unfunded-account") { throw new WalletServiceError( - "NETWORK_MISMATCH", - `Switch Freighter to Stellar ${STELLAR_NETWORK_LABELS[STELLAR_NETWORK]} and try again.`, + trustline === "missing" ? "TRUSTLINE_MISSING" : "UNFUNDED_ACCOUNT", + trustline === "missing" + ? "Add a USDC trustline before depositing." + : "Fund this Stellar account with XLM before depositing.", ); } - const server = createRpcServer(); let sourceAccount; try { - sourceAccount = await server.getAccount(access.address); + sourceAccount = await server.getAccount(address); } catch (error) { throw mapRpcError(error); } @@ -289,7 +377,7 @@ export async function submitVaultDeposit( .addOperation( new Contract(STELLAR_VAULT_CONTRACT_ID).call( "deposit", - new Address(access.address).toScVal(), + new Address(address).toScVal(), nativeToScVal(amountStroops, { type: "i128" }), ), ) @@ -307,13 +395,13 @@ export async function submitVaultDeposit( try { signature = await signTransaction(preparedTransaction.toXDR(), { networkPassphrase: STELLAR_NETWORK_PASSPHRASE, - address: access.address, + address, }); } catch (error) { throwWalletError(error); } if (signature.error) throwWalletError(signature.error); - if (!signature.signedTxXdr || signature.signerAddress !== access.address) { + if (!signature.signedTxXdr || signature.signerAddress !== address) { throw new WalletServiceError( "WALLET_ERROR", "Freighter did not sign the deposit with the connected account.",