Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/Deposit-Flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions src/App.deposit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 7 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function clearPendingDeposit(): void {
}

type WalletServiceFailure = Error & {
contractErrorCode?: number;
code: WalletServiceErrorCode;
requiresReconciliation: boolean;
};
Expand Down Expand Up @@ -469,6 +470,7 @@ function App() {
const [walletAddress, setWalletAddress] = useState("");
const [isAddingTrustline, setIsAddingTrustline] = useState(false);
const [trustlineCheckVersion, setTrustlineCheckVersion] = useState(0);
const [depositContractErrorCode, setDepositContractErrorCode] = useState<number | null>(null);
const [depositFailureCode, setDepositFailureCode] = useState<WalletServiceErrorCode | null>(null);
const [depositRequiresReconciliation, setDepositRequiresReconciliation] = useState(false);
const [txHash, setTxHash] = useState("");
Expand Down Expand Up @@ -774,6 +776,7 @@ function App() {
return;
}
setDepositFailureCode(failureCode);
setDepositContractErrorCode(walletError?.contractErrorCode ?? null);
const reqReconciliation = walletError?.requiresReconciliation ?? false;
setDepositRequiresReconciliation(reqReconciliation);
const finalHash = capturedHash || txHash;
Expand Down Expand Up @@ -1447,8 +1450,10 @@ function App() {
)}

{depositStage === "failed" && (
<article className="error-card">
<strong>{depositFailureCode === "SIGNATURE_REJECTED" ? "Signature rejected" : "Deposit failed"}</strong>
<article className="error-card" role="alert">
<strong>{depositFailureCode === "CONTRACT_REJECTED"
? `${statusMessage}${depositContractErrorCode === null ? "" : ` (code ${depositContractErrorCode})`}`
: depositFailureCode === "SIGNATURE_REJECTED" ? "Signature rejected" : "Deposit failed"}</strong>
<p>{statusMessage}</p>
{hasUnconfirmedDeposit && validityTimeRemaining !== null && validityTimeRemaining > 0 && (
<p className="helper-text" style={{ marginTop: "0.5rem" }}>
Expand Down
8 changes: 8 additions & 0 deletions src/data/mockApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/pages/ApiDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions src/services/vaultErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
53 changes: 53 additions & 0 deletions src/services/vaultErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { humanizeEvents, type xdr } from "@stellar/stellar-sdk";

const MESSAGES: Record<number, string> = {
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}).`,
};
}
35 changes: 33 additions & 2 deletions src/services/walletService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
}));

Expand All @@ -28,7 +30,7 @@ vi.mock("@stellar/freighter-api", () => ({

vi.mock("@stellar/stellar-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@stellar/stellar-sdk")>();
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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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" });
Expand Down
23 changes: 21 additions & 2 deletions src/services/walletService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StellarNetwork, string> = {
Expand All @@ -49,23 +51,27 @@ export type WalletServiceErrorCode =
| "INVALID_AMOUNT"
| "TRUSTLINE_MISSING"
| "UNFUNDED_ACCOUNT"
| "CONTRACT_REJECTED"
| "TRANSACTION_FAILED"
| "WALLET_ERROR"
| "NETWORK_ERROR";

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;
}
}

Expand Down Expand Up @@ -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 {
Expand Down