Skip to content

Commit af0a471

Browse files
authored
Merge pull request #173 from Benalex8797/feat/validation
Feat/validation
2 parents 9c9e075 + 6fc4d24 commit af0a471

6 files changed

Lines changed: 894 additions & 1 deletion

File tree

docs/api/ERROR_FORMAT.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# API Error Format
2+
3+
This document defines the **canonical error shapes** returned by the YieldVault
4+
API client layer. All errors — whether from the network, an HTTP status, or
5+
client-side request validation — conform to one of the two shapes below.
6+
7+
---
8+
9+
## 1. `ApiError` — Network & HTTP errors
10+
11+
Thrown by the `ApiClient` for any transport-level or HTTP-level failure.
12+
13+
### Shape
14+
15+
```ts
16+
interface ApiErrorShape {
17+
code: ApiErrorCode; // Machine-readable discriminant
18+
message: string; // Developer-facing explanation
19+
userMessage: string; // Safe to display in the UI
20+
retryable: boolean; // Whether an automatic retry is appropriate
21+
status?: number; // HTTP status code (if applicable)
22+
statusText?: string; // HTTP status text
23+
url?: string; // The URL that was requested
24+
method?: string; // HTTP method (GET, POST, …)
25+
traceId?: string; // x-trace-id header echoed from server
26+
correlationId?: string; // X-Correlation-ID for distributed tracing
27+
details?: unknown; // Raw response body (if parseable)
28+
}
29+
```
30+
31+
### Error Codes
32+
33+
| `code` | When raised | `retryable` |
34+
|--------------------|---------------------------------------------------------|-------------|
35+
| `NETWORK_ERROR` | `fetch()` throws a `TypeError` (no connectivity, DNS) | `true` |
36+
| `TIMEOUT` | Request exceeds the configured timeout | `true` |
37+
| `ABORTED` | Caller aborted the request via `AbortController` | `false` |
38+
| `HTTP_ERROR` | Server returned a non-2xx status code | see below |
39+
| `INVALID_RESPONSE` | Response body could not be parsed (malformed JSON) | `false` |
40+
| `UNKNOWN_ERROR` | Any other unclassified error | `false` |
41+
42+
> **Retryable HTTP statuses:** `408`, `425`, `429`, `500`, `502`, `503`, `504`
43+
44+
### Example
45+
46+
```json
47+
{
48+
"code": "HTTP_ERROR",
49+
"message": "Request failed with status 422.",
50+
"userMessage": "We could not complete that request. Please review your input and try again.",
51+
"retryable": false,
52+
"status": 422,
53+
"statusText": "Unprocessable Entity",
54+
"url": "https://api.yieldvault.io/v1/deposit",
55+
"method": "POST",
56+
"traceId": "abc123",
57+
"correlationId": "a87ff679-a2f3-461d-a2bf-3af783c070a3",
58+
"details": { "error": "Insufficient balance" }
59+
}
60+
```
61+
62+
---
63+
64+
## 2. `ValidationError` — Client-side request validation
65+
66+
Thrown by `validate()` / `validateAsync()` **before** any network call is made,
67+
when the caller supplies an invalid request payload or query parameter bag.
68+
69+
### Shape
70+
71+
```ts
72+
interface ValidationErrorShape {
73+
code: "VALIDATION_ERROR"; // Always this literal
74+
message: string; // Developer-facing summary
75+
userMessage: string; // Safe to show in UI
76+
details: ValidationErrorDetail[];
77+
}
78+
79+
interface ValidationErrorDetail {
80+
field: string; // Dot-path to the offending field (e.g. "amount")
81+
message: string; // Constraint violation description
82+
received?: string; // Sanitized received value (scalar only)
83+
}
84+
```
85+
86+
### Example
87+
88+
```json
89+
{
90+
"code": "VALIDATION_ERROR",
91+
"message": "Validation failed [DepositRequest]: Amount must be greater than zero",
92+
"userMessage": "Invalid value for \"amount\": Amount must be greater than zero.",
93+
"details": [
94+
{
95+
"field": "amount",
96+
"message": "Amount must be greater than zero",
97+
"received": "0"
98+
}
99+
]
100+
}
101+
```
102+
103+
---
104+
105+
## 3. Unified error handling pattern
106+
107+
Both error classes are exported from `src/lib/api` and can be narrowed with
108+
the provided type guards:
109+
110+
```ts
111+
import {
112+
isApiError,
113+
isValidationError,
114+
validate,
115+
DepositRequestSchema,
116+
} from "@/lib/api";
117+
118+
async function submitDeposit(raw: unknown) {
119+
// 1. Validate before sending — throws ValidationError on bad input
120+
const payload = validate(DepositRequestSchema, raw, "DepositRequest");
121+
122+
try {
123+
// 2. Call the API — throws ApiError on network / HTTP failure
124+
return await depositApi.post("/v1/deposit", { body: payload });
125+
} catch (err) {
126+
if (isValidationError(err)) {
127+
// Show err.userMessage in a form field
128+
console.error("Validation:", err.toJSON());
129+
} else if (isApiError(err)) {
130+
// Show err.userMessage in a toast / banner
131+
console.error("API:", err.code, err.status, err.correlationId);
132+
}
133+
throw err;
134+
}
135+
}
136+
```
137+
138+
---
139+
140+
## 4. Validated request schemas
141+
142+
| Schema | Covers |
143+
|-------------------------|-----------------------------------------------------|
144+
| `DepositRequestSchema` | Vault deposit: address, amount, asset, slippage |
145+
| `WithdrawalRequestSchema` | Vault withdrawal: address, shares, asset, dest |
146+
| `VaultHistoryQuerySchema` | History date range and item limit |
147+
| `PortfolioQuerySchema` | Holdings fetch with wallet address + status filter |
148+
| `TransactionQuerySchema`| Transaction history with limit, order, type filter |
149+
| `WalletAddressSchema` | Simple wallet-address-only param bags |
150+
151+
All schemas are exported from `src/lib/api` and built on the shared primitives
152+
`StellarAddressSchema` and `AmountSchema`.
153+
154+
---
155+
156+
## 5. Sensitive field policy
157+
158+
- **Wallet addresses** are logged as-is (public keys, not secrets).
159+
- **Amounts** are logged as-is (non-sensitive numeric values).
160+
- **Private keys / mnemonics** must never appear in request payloads and are
161+
not modelled in any schema.
162+
- The `received` field on `ValidationErrorDetail` is never populated for object
163+
or array values — only scalars — to prevent inadvertent secret leakage.

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"react": "^19.2.0",
2424
"react-dom": "^19.2.0",
2525
"react-router-dom": "^7.13.2",
26-
"recharts": "^3.8.1"
26+
"recharts": "^3.8.1",
27+
"zod": "^4.3.6"
2728
},
2829
"devDependencies": {
2930
"@eslint/js": "^9.39.1",

frontend/src/lib/api/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,31 @@ export {
2020
type ApiTelemetryEvent,
2121
} from "./telemetry";
2222
export { useApiTelemetry } from "./useApiTelemetry";
23+
export {
24+
ValidationError,
25+
isValidationError,
26+
validate,
27+
validateAsync,
28+
type ValidationErrorCode,
29+
type ValidationErrorDetail,
30+
type ValidationErrorShape,
31+
} from "./validation";
32+
export {
33+
StellarAddressSchema,
34+
AmountSchema,
35+
ShareCountSchema,
36+
AssetCodeSchema,
37+
IsoDatestamp,
38+
DepositRequestSchema,
39+
WithdrawalRequestSchema,
40+
VaultHistoryQuerySchema,
41+
PortfolioQuerySchema,
42+
WalletAddressSchema,
43+
TransactionQuerySchema,
44+
type DepositRequest,
45+
type WithdrawalRequest,
46+
type VaultHistoryQuery,
47+
type PortfolioQuery,
48+
type WalletAddressParam,
49+
type TransactionQuery,
50+
} from "./schemas";

frontend/src/lib/api/schemas.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* @file schemas.ts
3+
* Zod schemas for every request payload / query-parameter bag that the
4+
* YieldVault API client dispatches.
5+
*
6+
* Import the schema you need and pass it to `validate()` from ./validation
7+
* before calling any API function.
8+
*
9+
* Naming convention: <Entity><Action>Schema (e.g. DepositRequestSchema)
10+
*/
11+
12+
import { z } from "zod";
13+
14+
// ---------------------------------------------------------------------------
15+
// Shared primitives
16+
// ---------------------------------------------------------------------------
17+
18+
/**
19+
* Stellar / Soroban public key: G... base-32 address, 56 characters.
20+
* Validates format only — not an on-chain account existence check.
21+
*/
22+
export const StellarAddressSchema = z
23+
.string({ required_error: "Wallet address is required" })
24+
.trim()
25+
.regex(/^G[A-Z2-7]{55}$/, {
26+
message: "Must be a valid Stellar public key (starts with G, 56 chars)",
27+
});
28+
29+
/**
30+
* Positive decimal amount represented as a string (preserves precision).
31+
* Allows up to 7 decimal places to match Stellar's stroop precision.
32+
*/
33+
export const AmountSchema = z
34+
.string({ required_error: "Amount is required" })
35+
.trim()
36+
.regex(/^\d+(\.\d{1,7})?$/, {
37+
message: "Amount must be a positive number with up to 7 decimal places",
38+
})
39+
.refine((v) => parseFloat(v) > 0, {
40+
message: "Amount must be greater than zero",
41+
});
42+
43+
/** Positive integer share count. */
44+
export const ShareCountSchema = z
45+
.number({ required_error: "Share count is required", invalid_type_error: "Share count must be a number" })
46+
.int("Share count must be a whole number")
47+
.positive("Share count must be greater than zero")
48+
.max(1_000_000_000, "Share count exceeds maximum allowed value");
49+
50+
/** Supported asset codes. Extend as new assets are on-boarded. */
51+
export const AssetCodeSchema = z.enum(["XLM", "USDC", "yUSDC", "RWA"], {
52+
errorMap: () => ({ message: "Asset must be one of: XLM, USDC, yUSDC, RWA" }),
53+
});
54+
55+
/** ISO 8601 date string (YYYY-MM-DD). */
56+
export const IsoDatestamp = z
57+
.string()
58+
.regex(/^\d{4}-\d{2}-\d{2}$/, {
59+
message: "Date must be in YYYY-MM-DD format",
60+
});
61+
62+
// ---------------------------------------------------------------------------
63+
// Deposit request
64+
// ---------------------------------------------------------------------------
65+
66+
/**
67+
* Payload sent when a user deposits assets into a vault.
68+
*/
69+
export const DepositRequestSchema = z.object({
70+
walletAddress: StellarAddressSchema,
71+
amount: AmountSchema,
72+
asset: AssetCodeSchema,
73+
/** Optional slippage tolerance in basis points (0–500). */
74+
slippageBps: z
75+
.number()
76+
.int("Slippage must be a whole number of basis points")
77+
.min(0, "Slippage cannot be negative")
78+
.max(500, "Slippage tolerance may not exceed 500 bps (5%)")
79+
.optional(),
80+
});
81+
82+
export type DepositRequest = z.infer<typeof DepositRequestSchema>;
83+
84+
// ---------------------------------------------------------------------------
85+
// Withdrawal request
86+
// ---------------------------------------------------------------------------
87+
88+
/**
89+
* Payload sent when a user redeems vault shares for underlying assets.
90+
*/
91+
export const WithdrawalRequestSchema = z.object({
92+
walletAddress: StellarAddressSchema,
93+
shares: ShareCountSchema,
94+
asset: AssetCodeSchema,
95+
/** Optional destination override; defaults to walletAddress. */
96+
destinationAddress: StellarAddressSchema.optional(),
97+
slippageBps: z
98+
.number()
99+
.int("Slippage must be a whole number of basis points")
100+
.min(0, "Slippage cannot be negative")
101+
.max(500, "Slippage tolerance may not exceed 500 bps (5%)")
102+
.optional(),
103+
});
104+
105+
export type WithdrawalRequest = z.infer<typeof WithdrawalRequestSchema>;
106+
107+
// ---------------------------------------------------------------------------
108+
// Vault history query parameters
109+
// ---------------------------------------------------------------------------
110+
111+
/**
112+
* Query-string parameters for the vault performance history endpoint.
113+
*/
114+
export const VaultHistoryQuerySchema = z.object({
115+
from: IsoDatestamp.optional(),
116+
to: IsoDatestamp.optional(),
117+
/** Maximum number of data points to return (1–365). */
118+
limit: z
119+
.number()
120+
.int("Limit must be a whole number")
121+
.min(1, "Limit must be at least 1")
122+
.max(365, "Limit may not exceed 365 data points")
123+
.optional(),
124+
}).refine(
125+
(q) => {
126+
if (q.from && q.to) {
127+
return q.from <= q.to;
128+
}
129+
return true;
130+
},
131+
{ message: "\"from\" date must not be later than \"to\" date", path: ["from"] },
132+
);
133+
134+
export type VaultHistoryQuery = z.infer<typeof VaultHistoryQuerySchema>;
135+
136+
// ---------------------------------------------------------------------------
137+
// Portfolio holdings query parameters
138+
// ---------------------------------------------------------------------------
139+
140+
/**
141+
* Query-string parameters for the portfolio holdings endpoint.
142+
*/
143+
export const PortfolioQuerySchema = z.object({
144+
walletAddress: StellarAddressSchema,
145+
status: z.enum(["active", "pending", "all"]).optional().default("all"),
146+
});
147+
148+
export type PortfolioQuery = z.infer<typeof PortfolioQuerySchema>;
149+
150+
// ---------------------------------------------------------------------------
151+
// Wallet address lookup
152+
// ---------------------------------------------------------------------------
153+
154+
/**
155+
* Single-param schema used when an endpoint only needs the caller's address.
156+
*/
157+
export const WalletAddressSchema = z.object({
158+
walletAddress: StellarAddressSchema,
159+
});
160+
161+
export type WalletAddressParam = z.infer<typeof WalletAddressSchema>;
162+
163+
// ---------------------------------------------------------------------------
164+
// Transaction list query parameters
165+
// ---------------------------------------------------------------------------
166+
167+
/**
168+
* Query-string parameters for the transaction history endpoint.
169+
*/
170+
export const TransactionQuerySchema = z.object({
171+
walletAddress: StellarAddressSchema,
172+
/** Maximum number of records to return (1–200). */
173+
limit: z
174+
.number()
175+
.int("Limit must be a whole number")
176+
.min(1, "Limit must be at least 1")
177+
.max(200, "Limit may not exceed 200 records")
178+
.optional()
179+
.default(50),
180+
order: z.enum(["asc", "desc"]).optional().default("desc"),
181+
type: z.enum(["deposit", "withdrawal", "all"]).optional().default("all"),
182+
});
183+
184+
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;

0 commit comments

Comments
 (0)