@xochi/client 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Riddler's shielded terms preview wire (`POST /xochi/shielded/preview`,
3
+ * Riddler#1296): the typed request and response shapes and a decoder.
4
+ *
5
+ * There is NO transport method: the preview needs a `quote`-scoped Riddler
6
+ * credential, so only the Xochi Worker calls it, and the Worker proxy route is
7
+ * a follow-up owned by xochi#635. These shapes exist so that proxy and its
8
+ * consumers decode the solver's answer with the same schema the solver
9
+ * publishes (`packages/spec/wire/openapi.json`, `ShieldedPreviewResponse`).
10
+ *
11
+ * The preview is NOT a signing payload: `preview_digest` is the EIP-712 digest
12
+ * of `ShieldedPreviewV1` under the `Xochi Shielded Preview` domain, which no
13
+ * `ShieldedTermsV1` signature verifies against (`@xochi/spec`
14
+ * `conformance/shielded-terms.json`, PROPOSED). `executable` is always false.
15
+ */
16
+ import { z } from "zod";
17
+ import { ethAddress } from "./schemas.js";
18
+ /** 32-byte hex (`0x` + 64). */
19
+ const bytes32 = z.string().regex(/^0x[0-9a-fA-F]{64}$/);
20
+ /**
21
+ * A 32-byte Aztec address or field element. The decoder checks the shape only;
22
+ * the solver has already refused zero and values at or above the BN254 modulus.
23
+ * A 20-byte EVM address never fits.
24
+ */
25
+ export const aztecValue = bytes32;
26
+ /** Canonical unsigned decimal string (uint256 members). */
27
+ const uintString = z.string().regex(/^(0|[1-9][0-9]*)$/);
28
+ /** Canonical positive base-unit amount. */
29
+ const baseUnits = z.string().regex(/^[1-9][0-9]*$/);
30
+ export const ShieldedNetworkIdentitySchema = z.object({
31
+ aztec_chain_id: z.string().regex(/^[1-9][0-9]*$/),
32
+ protocol_version: z.string().regex(/^[1-9][0-9]*$/),
33
+ rollup_address: ethAddress,
34
+ l1_chain_id: z.string().regex(/^[1-9][0-9]*$/),
35
+ genesis_or_network_digest: bytes32,
36
+ });
37
+ export const ShieldedCorridorSchema = z.object({
38
+ corridor_id: bytes32,
39
+ revision: z.number().int().min(1).max(4294967295),
40
+ manifest_digest: bytes32,
41
+ });
42
+ export const ShieldedFeeBreakdownSchema = z.object({
43
+ gas_floor: uintString,
44
+ solver_fee_bps: z.number().int().min(0).max(10000),
45
+ solver_fee: uintString,
46
+ price_impact_bps: z.number().int().min(0).max(10000),
47
+ price_impact: uintString,
48
+ venue_fee_bps: z.number().int().min(0).max(10000),
49
+ venue_fee: uintString,
50
+ routing_fee_bps: z.number().int().min(0).max(10000),
51
+ routing_fee: uintString,
52
+ total_bps: z.number().int().min(0).max(40000),
53
+ total_fee: uintString,
54
+ });
55
+ /** `ShieldedPreviewV1` in snake_case: the terms minus the per-execution members. */
56
+ export const ShieldedPreviewSchema = z.object({
57
+ protocol_version: z.literal("xochi.shielded.preview.v1"),
58
+ environment: z.string().min(1),
59
+ source: z.object({
60
+ chain_id: z.number().int().positive(),
61
+ token_address: ethAddress,
62
+ wallet: ethAddress,
63
+ collector: ethAddress,
64
+ amount: baseUnits,
65
+ }),
66
+ destination: z.object({
67
+ settlement_type: z.literal("shielded"),
68
+ privacy_level: z.literal("private"),
69
+ disclosure_profile: z.literal("recipient-private-public-amount-v1"),
70
+ rail: z.literal("a_tracer"),
71
+ network: ShieldedNetworkIdentitySchema,
72
+ corridor: ShieldedCorridorSchema,
73
+ asset_id: z.string().min(1),
74
+ token_address: aztecValue,
75
+ recipient_binding_id: z.string().min(1),
76
+ recipient_descriptor_digest: bytes32,
77
+ recipient_address: aztecValue,
78
+ amount: baseUnits,
79
+ fee_payer: aztecValue,
80
+ }),
81
+ fee_breakdown: ShieldedFeeBreakdownSchema,
82
+ refund_address: ethAddress,
83
+ refund_amount: baseUnits,
84
+ collect_before: z.number().int().nonnegative(),
85
+ settlement_service_deadline: z.number().int().nonnegative(),
86
+ recipient_binding_digest: bytes32,
87
+ policy_revision: z.number().int().nonnegative(),
88
+ });
89
+ export const ShieldedPreviewResponseSchema = z.object({
90
+ executable: z.literal(false),
91
+ preview_version: z.literal("xochi.shielded.preview.v1"),
92
+ preview_digest: bytes32,
93
+ recipient_binding_digest: bytes32,
94
+ domain: z.object({
95
+ name: z.literal("Xochi Shielded Preview"),
96
+ version: z.literal("1"),
97
+ chain_id: z.number().int().positive(),
98
+ salt: bytes32,
99
+ }),
100
+ preview: ShieldedPreviewSchema,
101
+ manifest: z.object({
102
+ manifest_digest: bytes32,
103
+ aztec_sdk_version: z.string().min(1),
104
+ recipient_account_class_id: aztecValue,
105
+ }),
106
+ });
107
+ /**
108
+ * Decode a preview response. Throws (zod) on any shape the solver does not
109
+ * publish, including an executable or terms-shaped answer, and on a fee
110
+ * breakdown whose totals disagree: `total_fee` must be `max(gas_floor, layers)` (the gas
111
+ * floor is a minimum fee, as in FeePolicy) and `total_bps` the four rates.
112
+ */
113
+ export function decodeShieldedPreview(body) {
114
+ const decoded = ShieldedPreviewResponseSchema.parse(body);
115
+ const f = decoded.preview.fee_breakdown;
116
+ const layers = BigInt(f.solver_fee) + BigInt(f.price_impact) + BigInt(f.venue_fee) + BigInt(f.routing_fee);
117
+ const gasFloor = BigInt(f.gas_floor);
118
+ const totalFee = layers > gasFloor ? layers : gasFloor;
119
+ const totalBps = f.solver_fee_bps + f.price_impact_bps + f.venue_fee_bps + f.routing_fee_bps;
120
+ if (BigInt(f.total_fee) !== totalFee || f.total_bps !== totalBps) {
121
+ throw new Error("shielded preview fee_breakdown totals disagree with max(gas_floor, layers)");
122
+ }
123
+ return decoded;
124
+ }
@@ -38,7 +38,7 @@ export interface RetryPolicy {
38
38
  maxAttempts: number;
39
39
  /** First exponential backoff step. */
40
40
  baseDelayMs: number;
41
- /** Clamp on a server-supplied `Retry-After` used for internal pacing. */
41
+ /** Clamp on client-generated exponential backoff delays. */
42
42
  maxDelayMs: number;
43
43
  }
44
44
  export declare const DEFAULT_TIMEOUTS: TransportTimeouts;
@@ -119,8 +119,9 @@ export declare function takeApiErrorReadToken(error: unknown): string | undefine
119
119
  /**
120
120
  * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
121
121
  * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
122
- * client. Returns null when absent/unparseable so the caller falls back to its
123
- * own exponential backoff.
122
+ * client. The default safety ceiling preserves server-directed waits up to
123
+ * MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
124
+ * absent/unparseable so the caller falls back to its own exponential backoff.
124
125
  */
125
126
  export declare function parseRetryAfterMs(headerValue: string | null, maxDelayMs?: number): number | null;
126
127
  /**
@@ -130,11 +130,19 @@ function isNotAcceptedExecuteBody(status, body, intentId) {
130
130
  * MAX_RETRY_WAIT_SEC.
131
131
  */
132
132
  function notAcceptedRetryAt(headers, retryAfter) {
133
- const header = headers.get("Retry-After");
134
- const headerDelta = header === null || header.trim() === "" ? NaN : Number(header);
135
- const wait = Number.isFinite(headerDelta) && headerDelta > retryAfter ? headerDelta : retryAfter;
133
+ const headerDelta = retryAfterDeltaSeconds(headers.get("Retry-After"));
134
+ const wait = headerDelta !== null && headerDelta > retryAfter ? headerDelta : retryAfter;
136
135
  return Math.ceil((Date.now() + Math.min(wait, MAX_RETRY_WAIT_SEC) * 1000) / 1000);
137
136
  }
137
+ /** Retry-After's delta-seconds grammar: optional HTTP whitespace around 1*DIGIT. */
138
+ function retryAfterDeltaSeconds(headerValue) {
139
+ const value = headerValue?.trim();
140
+ if (!value || !/^\d+$/.test(value))
141
+ return null;
142
+ const seconds = Number(value);
143
+ // Syntactically valid integers beyond Number's range still take the 600 s cap.
144
+ return Number.isFinite(seconds) ? seconds : Number.MAX_SAFE_INTEGER;
145
+ }
138
146
  /**
139
147
  * Read tokens held for the errors `execute` rejected with, keyed by the error
140
148
  * so the token is never reachable from the error itself (a crash reporter or a
@@ -639,14 +647,13 @@ function readTokenFromPrefix(prefix, intentId) {
639
647
  /**
640
648
  * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
641
649
  * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
642
- * client. Returns null when absent/unparseable so the caller falls back to its
643
- * own exponential backoff.
650
+ * client. The default safety ceiling preserves server-directed waits up to
651
+ * MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
652
+ * absent/unparseable so the caller falls back to its own exponential backoff.
644
653
  */
645
- export function parseRetryAfterMs(headerValue, maxDelayMs = DEFAULT_RETRY.maxDelayMs) {
646
- if (!headerValue)
647
- return null;
648
- const seconds = Number(headerValue);
649
- if (!Number.isFinite(seconds) || seconds < 0)
654
+ export function parseRetryAfterMs(headerValue, maxDelayMs = MAX_RETRY_WAIT_SEC * 1000) {
655
+ const seconds = retryAfterDeltaSeconds(headerValue);
656
+ if (seconds === null)
650
657
  return null;
651
658
  return Math.min(seconds * 1000, maxDelayMs);
652
659
  }
@@ -694,12 +701,9 @@ export function resolveRetryAfterSeconds(headers, body) {
694
701
  return Math.min(target, nowSec + MAX_RETRY_WAIT_SEC);
695
702
  }
696
703
  function deriveRetryTarget(headers, body, nowSec) {
697
- const retryAfterHeader = headers.get("Retry-After");
698
- if (retryAfterHeader !== null) {
699
- const delta = Number(retryAfterHeader);
700
- if (Number.isFinite(delta) && delta >= 0)
701
- return nowSec + delta;
702
- }
704
+ const delta = retryAfterDeltaSeconds(headers.get("Retry-After"));
705
+ if (delta !== null)
706
+ return nowSec + delta;
703
707
  const reset = headers.get("X-RateLimit-Reset");
704
708
  if (reset !== null) {
705
709
  const abs = Number(reset);
@@ -1006,7 +1010,7 @@ export function createXochiClient(options) {
1006
1010
  // A 503 is a transient solver blip (saturation / gas-oracle
1007
1011
  // unavailable); pace the retry off the server's Retry-After.
1008
1012
  if (response.status === 503) {
1009
- retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"), retry.maxDelayMs);
1013
+ retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
1010
1014
  }
1011
1015
  // A cut body does not parse, so it never carries the marker.
1012
1016
  const record = truncated ? undefined : parseJsonObject(errorBody);
@@ -1062,7 +1066,7 @@ export function createXochiClient(options) {
1062
1066
  if (lastError.name === "AbortError")
1063
1067
  break;
1064
1068
  if (attempt < maxAttempts - 1) {
1065
- const delay = retryAfterMs ?? retry.baseDelayMs * Math.pow(2, attempt);
1069
+ const delay = retryAfterMs ?? Math.min(retry.baseDelayMs * Math.pow(2, attempt), retry.maxDelayMs);
1066
1070
  // A retry means the request already failed once: that is the context
1067
1071
  // for the error below if the retries run out.
1068
1072
  log("warn", `[xochi-client] Retry ${attempt + 1}/${maxAttempts} in ${delay}ms`);
@@ -1163,12 +1167,12 @@ export function createXochiClient(options) {
1163
1167
  return cached.quote;
1164
1168
  }
1165
1169
  const pending = pendingQuotes.get(requestHash);
1166
- if (pending && Date.now() - pending.timestamp < timeouts.requestTimeoutMs) {
1170
+ if (pending) {
1167
1171
  log("debug", "[xochi-client] Deduplicating quote request");
1168
- return pending.promise;
1172
+ return pending;
1169
1173
  }
1170
1174
  const promise = fetchQuote(request);
1171
- pendingQuotes.set(requestHash, { promise, timestamp: Date.now() });
1175
+ pendingQuotes.set(requestHash, promise);
1172
1176
  try {
1173
1177
  const quote = await promise;
1174
1178
  // Cache successful quotes -- EXCEPT pull-bearing ones. A quote carrying a
@@ -1181,7 +1185,8 @@ export function createXochiClient(options) {
1181
1185
  return quote;
1182
1186
  }
1183
1187
  finally {
1184
- pendingQuotes.delete(requestHash);
1188
+ if (pendingQuotes.get(requestHash) === promise)
1189
+ pendingQuotes.delete(requestHash);
1185
1190
  }
1186
1191
  },
1187
1192
  invalidateQuote(request) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xochi/client",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Xochi trade API client: transport, trade state machine, pre-sign fund-safety checks.",
5
5
  "keywords": [
6
6
  "xochi",