diff --git a/docs/Deposit-Flow.md b/docs/Deposit-Flow.md index f9c180f..da95673 100644 --- a/docs/Deposit-Flow.md +++ b/docs/Deposit-Flow.md @@ -182,3 +182,30 @@ Regression proof for row 2: on the original main branch, a wallet without the US 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. + +## Simulation failures before signing + +Deposits explicitly call `simulateTransaction`, inspect `rpc.Api.isSimulationError`, +and only assemble a successful response with `rpc.assembleTransaction(...).build()`. +Contract rejections use `CONTRACT_REJECTED`, carry the decoded `contractErrorCode` +when available, and never require reconciliation: nothing was signed or submitted. +The modal shows the specific message/code and offers Retry. Raw host diagnostics, +XDR, and wallet addresses are never displayed or logged; `SecureErrorHandler` +receives only the classification and decoded numeric code. + +| Vault code | Message/action | +| --- | --- | +| 1 | Vault is not initialized; contact the operator. | +| 4, 37 | Deposits are paused. | +| 6 | Amount must be positive. | +| 8 | Amount is below the vault minimum. | +| 9 | Amount cannot be accepted; try a smaller deposit. | +| 36 | Rate limit reached; wait before retrying. | +| 44 | Wallet is not on the deposit allowlist. | +| Other | Vault rejected the deposit, with the numeric code. | +| No contract code | Simulation could not prepare the deposit; no funds moved. | + +Real account/RPC transport exceptions keep their existing connection/timeout +messages. Failures after submission keep the existing transaction and +reconciliation behavior. The decoder reads both the simulation error string and +structured contract-error values in diagnostic events. diff --git a/src/App.deposit.test.tsx b/src/App.deposit.test.tsx index 43548df..324c448 100644 --- a/src/App.deposit.test.tsx +++ b/src/App.deposit.test.tsx @@ -425,6 +425,20 @@ describe("App deposit modal timed transitions", () => { expect(screen.getAllByText(/334\.62/).length).toBeGreaterThan(0); }); + it("shows the vault rejection message/code and allows retry without reconciliation", async () => { + const { WalletServiceError } = await import("./services/walletService"); + const error = new WalletServiceError("CONTRACT_REJECTED", "This wallet is not on the vault's deposit allowlist."); + Object.assign(error, { contractErrorCode: 44 }); + walletMocks.submitVaultDeposit.mockRejectedValueOnce(error); + const { amountInput } = await renderModalReady(); + setAmount(amountInput, "80"); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Approve deposit transaction" })); }); + expect(screen.getByRole("alert").textContent).toContain("allowlist. (code 44)"); + expect(screen.queryByRole("button", { name: "Check status" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Retry deposit" })); + expect((screen.getByPlaceholderText("0.00") as HTMLInputElement).value).toBe("80"); + }); + it("shows failure and retry restores the submitted amount", async () => { const { WalletServiceError } = await import("./services/walletService"); walletMocks.submitVaultDeposit.mockRejectedValue( diff --git a/src/App.tsx b/src/App.tsx index a7d0718..7f16e67 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -117,6 +117,7 @@ export function clearPendingDeposit(): void { } type WalletServiceFailure = Error & { + contractErrorCode?: number; code: WalletServiceErrorCode; requiresReconciliation: boolean; }; @@ -469,6 +470,7 @@ function App() { const [walletAddress, setWalletAddress] = useState(""); const [isAddingTrustline, setIsAddingTrustline] = useState(false); const [trustlineCheckVersion, setTrustlineCheckVersion] = useState(0); + const [depositContractErrorCode, setDepositContractErrorCode] = useState(null); const [depositFailureCode, setDepositFailureCode] = useState(null); const [depositRequiresReconciliation, setDepositRequiresReconciliation] = useState(false); const [txHash, setTxHash] = useState(""); @@ -774,6 +776,7 @@ function App() { return; } setDepositFailureCode(failureCode); + setDepositContractErrorCode(walletError?.contractErrorCode ?? null); const reqReconciliation = walletError?.requiresReconciliation ?? false; setDepositRequiresReconciliation(reqReconciliation); const finalHash = capturedHash || txHash; @@ -1447,8 +1450,10 @@ function App() { )} {depositStage === "failed" && ( -
- {depositFailureCode === "SIGNATURE_REJECTED" ? "Signature rejected" : "Deposit failed"} +
+ {depositFailureCode === "CONTRACT_REJECTED" + ? `${statusMessage}${depositContractErrorCode === null ? "" : ` (code ${depositContractErrorCode})`}` + : depositFailureCode === "SIGNATURE_REJECTED" ? "Signature rejected" : "Deposit failed"}

{statusMessage}

{hasUnconfirmedDeposit && validityTimeRemaining !== null && validityTimeRemaining > 0 && (

diff --git a/src/data/mockApis.ts b/src/data/mockApis.ts index ddd76d5..bc5e1b2 100644 --- a/src/data/mockApis.ts +++ b/src/data/mockApis.ts @@ -36,6 +36,14 @@ export type APIItem = { sparklineValues?: number[]; pricingOnRequest?: boolean; baseUrl?: string; + sla?: { + uptimeTargetPct?: number; + p99LatencyMs?: number; + incidentResponseMinutes?: number; + maintenanceWindow?: string; + supportTier?: string; + creditThresholdPct?: number; + }; rateLimits?: Array<{ plan: string; requestsPerMinute: number; diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index 64679a3..3b87a63 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -25,12 +25,12 @@ import StatusBadge, { apiStatusToVariant } from "../components/StatusBadge"; import { useToast } from "../components/Toast"; import { useCollections } from "../state/collectionsStore"; import RelatedApisRail from "../components/RelatedApisRail"; -import MOCK_APIS from "../data/mockApis"; +import MOCK_APIS, { type APIItem as ApiDetail } from "../data/mockApis"; import KbdHint from "../components/KbdHint"; import { SHORTCUTS } from "../hooks/useGlobalShortcuts"; import LiveRegion from "../components/LiveRegion"; import { toCurl, type CurlRequest } from "../utils/toCurl"; -import { fetchApiDetail, type ApiDetail } from "../api/catalogApi"; +import { fetchApiDetail } from "../api/catalogApi"; /** * ApiDetailPage @@ -698,7 +698,7 @@ export default function ApiDetailPage({ onBack }: Props) { fetchApiDetail(id, controller.signal) .then((result) => { if (!active) return; - if (result.kind === "not-found") { + if (result.status === "not-found") { setApi(null); setStatus("not-found"); return; diff --git a/src/services/vaultErrors.test.ts b/src/services/vaultErrors.test.ts new file mode 100644 index 0000000..b74d387 --- /dev/null +++ b/src/services/vaultErrors.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { nativeToScVal, xdr } from "@stellar/stellar-sdk"; +import { decodeVaultError } from "./vaultErrors"; + +function diagnostic(value: xdr.ScVal): xdr.DiagnosticEvent { + return new xdr.DiagnosticEvent({ + inSuccessfulContractCall: false, + event: new xdr.ContractEvent({ + ext: xdr.ExtensionPoint.v0(), contractId: null, + type: xdr.ContractEventType.diagnostic, + body: xdr.ContractEventBody.v0(new xdr.ContractEventV0({ topics: [value], data: nativeToScVal("private diagnostic") })), + }), + }); +} + +describe("decodeVaultError", () => { + it.each([ + [1, "not initialized"], [4, "paused"], [6, "greater than zero"], + [8, "below the vault minimum"], [9, "smaller deposit"], [36, "rate limit"], + [37, "paused"], [44, "allowlist"], [99, "code 99"], + ])("maps contract code %s without exposing host diagnostics", (code, text) => { + const decoded = decodeVaultError({ error: `HostError: Error(Contract, #${code}) XDR secret-address` }); + expect(decoded.code).toBe(code); + expect(decoded.message).toContain(text); + expect(decoded.message).not.toMatch(/HostError|XDR|secret-address/); + }); + it("decodes a structured contract error in a real SDK diagnostic event", () => { + expect(decodeVaultError({ error: "simulation failed", events: [diagnostic(xdr.ScVal.scvError(xdr.ScError.sceContract(44)))] })) + .toEqual({ code: 44, message: "This wallet is not on the vault's deposit allowlist." }); + }); + it("ignores malformed/non-contract events and searches later diagnostics", () => { + const events = [null as unknown as xdr.DiagnosticEvent, diagnostic(nativeToScVal(false)), diagnostic(nativeToScVal("Error(Contract, #8)"))]; + expect(decodeVaultError({ events }).code).toBe(8); + }); + it("does not mistake system error numbers or arbitrary text for contract codes", () => { + expect(decodeVaultError({ error: "HostError: Error(Storage, #44)", events: [diagnostic(nativeToScVal(44))] })) + .toEqual({ message: "The vault could not simulate this deposit. No funds were moved." }); + expect(decodeVaultError({}).code).toBeUndefined(); + }); +}); diff --git a/src/services/vaultErrors.ts b/src/services/vaultErrors.ts new file mode 100644 index 0000000..026778f --- /dev/null +++ b/src/services/vaultErrors.ts @@ -0,0 +1,53 @@ +import { humanizeEvents, type xdr } from "@stellar/stellar-sdk"; + +const MESSAGES: Record = { + 1: "The vault is not initialized. Contact the vault operator.", + 4: "Deposits are paused on the vault right now.", + 6: "Deposit amount must be greater than zero.", + 8: "Amount is below the vault minimum.", + 9: "The vault cannot accept this amount. Try a smaller deposit.", + 36: "The vault deposit rate limit was reached. Wait before retrying.", + 37: "Deposits are paused on the vault right now.", + 44: "This wallet is not on the vault's deposit allowlist.", +}; + +function contractCode(value: unknown): number | undefined { + if (typeof value === "string") { + const match = /Error\(Contract,\s*#(\d+)\)/.exec(value); + if (match) return Number(match[1]); + } + if (value && typeof value === "object") { + if ("type" in value && value.type === "contract" && "code" in value && + typeof value.code === "number" && Number.isInteger(value.code) && value.code >= 0) { + return value.code; + } + for (const child of Object.values(value)) { + const code = contractCode(child); + if (code !== undefined) return code; + } + } + return undefined; +} + +/** Decode only contract error codes; raw host errors/XDR never become UI text. */ +export function decodeVaultError(simulation: { error?: string; events?: xdr.DiagnosticEvent[] }): { + code?: number; + message: string; +} { + let code = contractCode(simulation.error); + if (code === undefined) { + for (const event of simulation.events ?? []) { + try { + code = contractCode(humanizeEvents([event])); + if (code !== undefined) break; + } catch { + // A malformed diagnostic must not hide the original simulation failure. + } + } + } + return { + ...(code === undefined ? {} : { code }), + message: code === undefined ? "The vault could not simulate this deposit. No funds were moved." + : MESSAGES[code] ?? `The vault rejected this deposit (code ${code}).`, + }; +} diff --git a/src/services/walletService.test.ts b/src/services/walletService.test.ts index f90204b..a57441e 100644 --- a/src/services/walletService.test.ts +++ b/src/services/walletService.test.ts @@ -13,9 +13,11 @@ const mocks = vi.hoisted(() => ({ server: { getAccount: vi.fn(), prepareTransaction: vi.fn(), + simulateTransaction: vi.fn(), sendTransaction: vi.fn(), getTransaction: vi.fn(), }, + assembleTransaction: vi.fn((transaction: unknown) => ({ build: () => transaction })), walletSnapshot: vi.fn(() => ({ isNetworkMismatch: false })), })); @@ -28,7 +30,7 @@ vi.mock("@stellar/freighter-api", () => ({ vi.mock("@stellar/stellar-sdk", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, rpc: { ...actual.rpc, Server: class { + return { ...actual, rpc: { ...actual.rpc, assembleTransaction: mocks.assembleTransaction, Server: class { constructor(...args: unknown[]) { mocks.serverConstructor(...args); return mocks.server; @@ -75,6 +77,7 @@ describe("walletService", () => { }); mocks.signTransaction.mockImplementation(async (xdr: string) => ({ signedTxXdr: xdr, signerAddress: walletAddress })); mocks.server.getAccount.mockImplementation(async () => new Account(walletAddress, "123")); + mocks.server.simulateTransaction.mockReset().mockResolvedValue({ _parsed: true, latestLedger: 1, events: [], result: {} }); mocks.server.prepareTransaction.mockImplementation(async (transaction: Transaction) => transaction); mocks.server.sendTransaction.mockResolvedValue({ status: "PENDING", @@ -325,7 +328,7 @@ describe("walletService", () => { }); it("does not request a deposit signature if simulation fails", async () => { - mocks.server.prepareTransaction.mockRejectedValueOnce(new Error("simulation failed")); + mocks.server.simulateTransaction.mockRejectedValueOnce(new Error("simulation failed")); await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "NETWORK_ERROR" }); expect(mocks.signTransaction).not.toHaveBeenCalled(); }); @@ -356,6 +359,34 @@ describe("walletService", () => { await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "TRANSACTION_FAILED", requiresReconciliation: false }); }); + it.each([4, 37, 8, 44, 99])("rejects contract simulation code %s before signing or submitting", async (code) => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.server.simulateTransaction.mockResolvedValueOnce({ error: `HostError: Error(Contract, #${code}) PRIVATE_XDR ${walletAddress}`, events: [] }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ + code: "CONTRACT_REJECTED", contractErrorCode: code, requiresReconciliation: false, + }); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + expect(mocks.server.sendTransaction).not.toHaveBeenCalled(); + const payload = JSON.stringify(logged.mock.calls); + expect(payload).toContain(String(code)); + expect(payload).not.toMatch(/PRIVATE_XDR|HostError/); + expect(payload).not.toContain(walletAddress); + }); + + it("preserves simulation timeout and unreachable-RPC messages", async () => { + for (const [error, expected] of [["timeout", "timed out before"], ["offline", "Could not reach Stellar RPC"]]) { + mocks.server.simulateTransaction.mockRejectedValueOnce(new Error(error)); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "NETWORK_ERROR", message: expect.stringContaining(expected), requiresReconciliation: false }); + } + expect(mocks.signTransaction).not.toHaveBeenCalled(); + }); + + it("does not classify a local assembly failure as a transport error", async () => { + mocks.assembleTransaction.mockImplementationOnce(() => { throw new Error("raw simulation XDR"); }); + await expect(submitVaultDeposit("50")).rejects.toMatchObject({ code: "CONTRACT_REJECTED", requiresReconciliation: false }); + expect(mocks.signTransaction).not.toHaveBeenCalled(); + }); + describe("checkDepositStatus", () => { it("returns SUCCESS when getTransaction reports SUCCESS", async () => { mocks.server.getTransaction.mockResolvedValueOnce({ status: "SUCCESS" }); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 318d394..99e7a30 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -27,6 +27,8 @@ import { STELLAR_VAULT_CONTRACT_ID, type StellarNetwork, } from "../config/constants"; +import { decodeVaultError } from "./vaultErrors"; +import { logError } from "./SecureErrorHandler"; import { walletStore } from "../state/walletStore"; const STELLAR_NETWORK_LABELS: Record = { @@ -49,6 +51,7 @@ export type WalletServiceErrorCode = | "INVALID_AMOUNT" | "TRUSTLINE_MISSING" | "UNFUNDED_ACCOUNT" + | "CONTRACT_REJECTED" | "TRANSACTION_FAILED" | "WALLET_ERROR" | "NETWORK_ERROR"; @@ -56,16 +59,19 @@ export type WalletServiceErrorCode = export class WalletServiceError extends Error { readonly code: WalletServiceErrorCode; readonly requiresReconciliation: boolean; + readonly contractErrorCode?: number; constructor( code: WalletServiceErrorCode, message: string, requiresReconciliation = false, + contractErrorCode?: number, ) { super(message); this.name = "WalletServiceError"; this.code = code; this.requiresReconciliation = requiresReconciliation; + this.contractErrorCode = contractErrorCode; } } @@ -392,12 +398,25 @@ export async function submitVaultDeposit( .setTimeout(TRANSACTION_TIMEOUT_SECONDS) .build(); - let preparedTransaction; + let simulation; try { - preparedTransaction = await server.prepareTransaction(transaction); + simulation = await server.simulateTransaction(transaction); } catch (error) { throw mapRpcError(error); } + if (rpc.Api.isSimulationError(simulation)) { + const decoded = decodeVaultError(simulation); + logError("vault-deposit", "CONTRACT_REJECTED", { contractErrorCode: decoded.code }); + throw new WalletServiceError("CONTRACT_REJECTED", decoded.message, false, decoded.code); + } + // Assembly is local, not a transport operation. Do not mislabel malformed or + // restoration-required simulation responses as connectivity failures. + let preparedTransaction; + try { + preparedTransaction = rpc.assembleTransaction(transaction, simulation).build(); + } catch { + throw new WalletServiceError("CONTRACT_REJECTED", "The vault could not prepare this deposit. No funds were moved."); + } let signature; try {