@xochi/client 0.0.0-stage → 0.1.2

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -2
  4. package/dist/cjs/claim.d.ts +33 -0
  5. package/dist/cjs/claim.js +44 -0
  6. package/dist/cjs/fee.d.ts +247 -0
  7. package/dist/cjs/fee.js +295 -0
  8. package/dist/cjs/index.d.ts +18 -0
  9. package/dist/cjs/index.js +33 -0
  10. package/dist/cjs/machine/claimInfo.d.ts +29 -0
  11. package/dist/cjs/machine/claimInfo.js +26 -0
  12. package/dist/cjs/machine/decisions.d.ts +452 -0
  13. package/dist/cjs/machine/decisions.js +665 -0
  14. package/dist/cjs/machine/deposit.d.ts +113 -0
  15. package/dist/cjs/machine/deposit.js +265 -0
  16. package/dist/cjs/machine/index.d.ts +23 -0
  17. package/dist/cjs/machine/index.js +53 -0
  18. package/dist/cjs/machine/machine.d.ts +411 -0
  19. package/dist/cjs/machine/machine.js +1627 -0
  20. package/dist/cjs/machine/phase.d.ts +24 -0
  21. package/dist/cjs/machine/phase.js +45 -0
  22. package/dist/cjs/machine/quoteChecks.d.ts +41 -0
  23. package/dist/cjs/machine/quoteChecks.js +82 -0
  24. package/dist/cjs/machine/receipt.d.ts +151 -0
  25. package/dist/cjs/machine/receipt.js +69 -0
  26. package/dist/cjs/machine/recipientEcho.d.ts +16 -0
  27. package/dist/cjs/machine/recipientEcho.js +27 -0
  28. package/dist/cjs/machine/solverPin.d.ts +110 -0
  29. package/dist/cjs/machine/solverPin.js +214 -0
  30. package/dist/cjs/machine/state.d.ts +167 -0
  31. package/dist/cjs/machine/state.js +31 -0
  32. package/dist/cjs/machine/transition.d.ts +15 -0
  33. package/dist/cjs/machine/transition.js +20 -0
  34. package/dist/cjs/machine/validateRequest.d.ts +15 -0
  35. package/dist/cjs/machine/validateRequest.js +127 -0
  36. package/dist/cjs/machine/view.d.ts +3 -0
  37. package/dist/cjs/machine/view.js +74 -0
  38. package/dist/cjs/machine/wire.d.ts +527 -0
  39. package/dist/cjs/machine/wire.js +280 -0
  40. package/dist/cjs/package.json +1 -0
  41. package/dist/cjs/shared/api.d.ts +9 -0
  42. package/dist/cjs/shared/api.js +5 -0
  43. package/dist/cjs/shared/chains.d.ts +243 -0
  44. package/dist/cjs/shared/chains.js +250 -0
  45. package/dist/cjs/shared/eip712.d.ts +44 -0
  46. package/dist/cjs/shared/eip712.js +64 -0
  47. package/dist/cjs/shared/index.d.ts +16 -0
  48. package/dist/cjs/shared/index.js +32 -0
  49. package/dist/cjs/shared/intentStream.d.ts +74 -0
  50. package/dist/cjs/shared/intentStream.js +59 -0
  51. package/dist/cjs/shared/intents.d.ts +88 -0
  52. package/dist/cjs/shared/intents.js +227 -0
  53. package/dist/cjs/shared/schemas.d.ts +928 -0
  54. package/dist/cjs/shared/schemas.js +524 -0
  55. package/dist/cjs/shared/shieldedPreview.d.ts +841 -0
  56. package/dist/cjs/shared/shieldedPreview.js +128 -0
  57. package/dist/cjs/shared/stealth.d.ts +53 -0
  58. package/dist/cjs/shared/stealth.js +68 -0
  59. package/dist/cjs/shared/trading.d.ts +153 -0
  60. package/dist/cjs/shared/trading.js +185 -0
  61. package/dist/cjs/shared/types.d.ts +37 -0
  62. package/dist/cjs/shared/types.js +7 -0
  63. package/dist/cjs/shared/validators.d.ts +30 -0
  64. package/dist/cjs/shared/validators.js +79 -0
  65. package/dist/cjs/transport/client.d.ts +234 -0
  66. package/dist/cjs/transport/client.js +1456 -0
  67. package/dist/esm/claim.d.ts +33 -0
  68. package/dist/esm/claim.js +41 -0
  69. package/dist/esm/fee.d.ts +247 -0
  70. package/dist/esm/fee.js +285 -0
  71. package/dist/esm/index.d.ts +18 -0
  72. package/dist/esm/index.js +17 -0
  73. package/dist/esm/machine/claimInfo.d.ts +29 -0
  74. package/dist/esm/machine/claimInfo.js +23 -0
  75. package/dist/esm/machine/decisions.d.ts +452 -0
  76. package/dist/esm/machine/decisions.js +640 -0
  77. package/dist/esm/machine/deposit.d.ts +113 -0
  78. package/dist/esm/machine/deposit.js +260 -0
  79. package/dist/esm/machine/index.d.ts +23 -0
  80. package/dist/esm/machine/index.js +23 -0
  81. package/dist/esm/machine/machine.d.ts +411 -0
  82. package/dist/esm/machine/machine.js +1621 -0
  83. package/dist/esm/machine/phase.d.ts +24 -0
  84. package/dist/esm/machine/phase.js +42 -0
  85. package/dist/esm/machine/quoteChecks.d.ts +41 -0
  86. package/dist/esm/machine/quoteChecks.js +78 -0
  87. package/dist/esm/machine/receipt.d.ts +151 -0
  88. package/dist/esm/machine/receipt.js +64 -0
  89. package/dist/esm/machine/recipientEcho.d.ts +16 -0
  90. package/dist/esm/machine/recipientEcho.js +24 -0
  91. package/dist/esm/machine/solverPin.d.ts +110 -0
  92. package/dist/esm/machine/solverPin.js +210 -0
  93. package/dist/esm/machine/state.d.ts +167 -0
  94. package/dist/esm/machine/state.js +28 -0
  95. package/dist/esm/machine/transition.d.ts +15 -0
  96. package/dist/esm/machine/transition.js +17 -0
  97. package/dist/esm/machine/validateRequest.d.ts +15 -0
  98. package/dist/esm/machine/validateRequest.js +123 -0
  99. package/dist/esm/machine/view.d.ts +3 -0
  100. package/dist/esm/machine/view.js +71 -0
  101. package/dist/esm/machine/wire.d.ts +527 -0
  102. package/dist/esm/machine/wire.js +266 -0
  103. package/dist/esm/shared/api.d.ts +9 -0
  104. package/dist/esm/shared/api.js +4 -0
  105. package/dist/esm/shared/chains.d.ts +243 -0
  106. package/dist/esm/shared/chains.js +236 -0
  107. package/dist/esm/shared/eip712.d.ts +44 -0
  108. package/dist/esm/shared/eip712.js +61 -0
  109. package/dist/esm/shared/index.d.ts +16 -0
  110. package/dist/esm/shared/index.js +16 -0
  111. package/dist/esm/shared/intentStream.d.ts +74 -0
  112. package/dist/esm/shared/intentStream.js +55 -0
  113. package/dist/esm/shared/intents.d.ts +88 -0
  114. package/dist/esm/shared/intents.js +213 -0
  115. package/dist/esm/shared/schemas.d.ts +928 -0
  116. package/dist/esm/shared/schemas.js +521 -0
  117. package/dist/esm/shared/shieldedPreview.d.ts +841 -0
  118. package/dist/esm/shared/shieldedPreview.js +124 -0
  119. package/dist/esm/shared/stealth.d.ts +53 -0
  120. package/dist/esm/shared/stealth.js +64 -0
  121. package/dist/esm/shared/trading.d.ts +153 -0
  122. package/dist/esm/shared/trading.js +182 -0
  123. package/dist/esm/shared/types.d.ts +37 -0
  124. package/dist/esm/shared/types.js +6 -0
  125. package/dist/esm/shared/validators.d.ts +30 -0
  126. package/dist/esm/shared/validators.js +74 -0
  127. package/dist/esm/transport/client.d.ts +234 -0
  128. package/dist/esm/transport/client.js +1447 -0
  129. package/package.json +61 -4
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Zod Schemas
3
+ *
4
+ * The Worker's quote and execute RESPONSE wire contract (`/api/intent/quote`,
5
+ * `/api/intent/execute`), with the primitives it closes over. Types are derived
6
+ * from schemas via z.infer -- single source of truth.
7
+ */
8
+ import { z } from "zod";
9
+ import { ETH_ADDRESS_REGEX } from "./validators.js";
10
+ import { NOT_EXECUTED_SUBSTATUS } from "./intents.js";
11
+ // ============================================================
12
+ // Primitives
13
+ // ============================================================
14
+ /** Ethereum address: 0x-prefixed, 42 hex chars. Single source: validators.ts. */
15
+ export const ethAddress = z.string().regex(ETH_ADDRESS_REGEX);
16
+ /**
17
+ * Tron base58check address: leading `T`, 34 chars, base58 alphabet (no
18
+ * 0/O/I/l). Structural check mirroring `TRON_ADDRESS_REGEX` in validators.ts;
19
+ * kept inline here so the schema module stays free of the viem import chain.
20
+ */
21
+ export const tronAddress = z
22
+ .string()
23
+ .regex(/^T[1-9A-HJ-NP-Za-km-z]{33}$/, "Must be a Tron base58 address");
24
+ /**
25
+ * Recipient address for a non-EVM destination: the user's own address on the
26
+ * destination chain (EVM routes settle to the signing wallet, so it is
27
+ * optional). EVM hex20 or Tron base58.
28
+ */
29
+ export const recipientAddress = z.union([ethAddress, tronAddress]);
30
+ /** Settlement type */
31
+ export const settlementType = z
32
+ .enum(["public", "shielded", "stealth"])
33
+ .describe("Where the payout lands. `public` settles to the wallet address. `stealth` settles to a one-time address on Ethereum L1 (ERC-5564). `shielded` (Aztec L2 notes) is planned and not yet live: it is gated by SHIELDED_SETTLEMENT_LIVE, served as unavailable in `settlement_options`, and refused as a `settlement_preference` while that is off. Read `settlement_options` for live availability rather than assuming this enum's members are all fillable.");
34
+ /**
35
+ * Pull+gas payment method the quote's eip712 authorizes.
36
+ *
37
+ * `deposit` is the odd one out and is not a pull at all: on a non-EVM origin
38
+ * (Tron) the user sends the tokens themselves to a served `deposit_address`,
39
+ * at their own network cost, with no wallet signature anywhere in the flow.
40
+ * It is in this enum because it is what belongs in this FIELD, which answers
41
+ * "how does the origin leg get funded".
42
+ *
43
+ * Without it the wire lies: a Tron-origin quote reports `payment_method:
44
+ * "permit2"` next to `gasless: false` and a `deposit_address`, describing a
45
+ * Permit2 pull on a chain that has no Permit2. The browser papers over it
46
+ * (`resolvePaymentMethod` overrides to `deposit` whenever a deposit address is
47
+ * present), so a human never sees the wrong label; every non-browser consumer
48
+ * does: the MCP server, the OpenAPI contract, and any agent branching on this
49
+ * field to decide whether to prepare a signature.
50
+ */
51
+ export const paymentMethod = z.enum([
52
+ "erc3009",
53
+ "permit2",
54
+ "pimlico",
55
+ "x402",
56
+ "mpp",
57
+ "approval",
58
+ "deposit",
59
+ ]);
60
+ // ============================================================
61
+ // Referral and promo outcomes (quote response siblings of fee_breakdown)
62
+ // ============================================================
63
+ /**
64
+ * A referral code as stored (xochi#583): 3-32 of `A-Z 0-9 _ -`, starting with a
65
+ * letter or digit. Stored and compared uppercase, so a link's `?ref=summer` and
66
+ * `SUMMER` are the same code. Migration 0019 enforces the same shape.
67
+ */
68
+ export const REFERRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{2,31}$/;
69
+ /**
70
+ * The note served when a `ref` was sent and is not applied. ONE text for every
71
+ * reason (unknown, expired, revoked, scoped to another key, a key that does not
72
+ * accept referrals, self-referral): a per-reason note would let a caller
73
+ * enumerate which codes exist.
74
+ */
75
+ export const REFERRAL_NOT_APPLIED_NOTE = "Referral code not applied.";
76
+ /**
77
+ * The neutral referral line (xochi#603): served on a quote whose `ref` was
78
+ * applied when the request is not signed in as the quoting wallet. One fixed
79
+ * text, identical whether or not the wallet has a referral binding.
80
+ */
81
+ export const REFERRAL_NEUTRAL_NOTE = "A referrer may earn part of the routing fee on this trade.";
82
+ export const QuoteReferralSchema = z
83
+ .union([
84
+ z
85
+ .object({
86
+ code: z
87
+ .string()
88
+ .regex(REFERRAL_CODE_REGEX)
89
+ .describe("The code this trade credits, normalized uppercase."),
90
+ name: z
91
+ .string()
92
+ .describe("Affiliate display name, rendered as `Referral: {name}` on the routing line."),
93
+ })
94
+ .strict()
95
+ .describe("Served only when the request carries a wallet-bound session (SIWE JWT or Mandate) for the quoting wallet: the code the trade will credit if executed now. The wallet's open referral binding wins over `ref`; otherwise it is the applied `ref`."),
96
+ z
97
+ .object({
98
+ neutral: z.literal(true),
99
+ note: z.literal(REFERRAL_NEUTRAL_NOTE),
100
+ })
101
+ .strict()
102
+ .describe("Served when `ref` was applied but the request is not signed in as the quoting wallet: a referrer may be credited, and who is not said. Identical whether or not the wallet has a referral binding."),
103
+ ])
104
+ .describe("Who earns the routing fee layer (`fee_breakdown.routing_fee`) in place of the publishable key's routing recipient. Attribution only: no amount, rate or total differs from the same quote without `ref`.");
105
+ /**
106
+ * A promo code as stored (xochi#584): 3-32 of `A-Z 0-9 _ -`, starting with a
107
+ * letter or digit. Stored and compared uppercase, so `launch10` and `LAUNCH10`
108
+ * are the same code. Migration 0020 enforces the same shape.
109
+ */
110
+ export const PROMO_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{2,31}$/;
111
+ /**
112
+ * The inline error served when a `promo` was sent and cannot be used. ONE text
113
+ * for every reason (unknown, malformed, expired, revoked, used up, used up by
114
+ * this wallet, not accepted under this publishable key, lookup ceiling, the
115
+ * feature off): a per-reason message would let a caller enumerate which codes
116
+ * exist.
117
+ */
118
+ export const PROMO_NOT_APPLIED_NOTE = "This code can't be applied.";
119
+ /**
120
+ * The note served when a valid code was sent but the caller's trust tier
121
+ * already discounts at least as much (discount = max(tier, capped promo), never
122
+ * both; an Institutional wallet always lands here). The code is
123
+ * not consumed.
124
+ */
125
+ export const PROMO_TIER_BETTER_NOTE = "Your trust tier already gives you a fee at least as low as this code.";
126
+ export const QuotePromoSchema = z
127
+ .object({
128
+ code: z.string().regex(PROMO_CODE_REGEX).describe("The applied code, normalized uppercase."),
129
+ discount_bps: z
130
+ .number()
131
+ .int()
132
+ .positive()
133
+ .describe("Discount this code bought, in bps off the Standard venue + routing layers for the route's asset class. Already reflected in every amount of this quote and itemized as `fee_breakdown.discount`."),
134
+ })
135
+ .strict()
136
+ .describe("Present when the `promo` sent on the quote was applied: it beat the caller's trust-tier discount and priced this quote. One use of the code is counted when this quote is executed, not now; if the code is used up or withdrawn by then, execute refuses with `promo_unavailable` and the caller re-quotes.");
137
+ // ============================================================
138
+ // Response Schemas (worker -> client/agent wire contract)
139
+ // ============================================================
140
+ /**
141
+ * EIP-712 typed data the user signs. Internal keys follow the EIP-712
142
+ * standard (camelCase: domain.chainId, domain.verifyingContract, primaryType)
143
+ * and are part of the signed payload -- they are intentionally NOT snake_cased.
144
+ * Passed through verbatim from the solver. Mirrors the `Eip712TypedData`
145
+ * schema in public/openapi.yaml.
146
+ */
147
+ export const Eip712TypedDataSchema = z.object({
148
+ // Two domain shapes coexist: legacy Xochi-owned structs and token-pull
149
+ // domains carry `verifyingContract`; the Riddler XochiIntent v3 domain carries
150
+ // `salt` (keccak256(version)) and NO verifyingContract. Both fields are
151
+ // optional so either shape validates and both survive the parse verbatim -- a
152
+ // stripped `salt` or an injected `verifyingContract` would change the domain
153
+ // separator and strand the signature.
154
+ domain: z.object({
155
+ name: z.string(),
156
+ version: z.string(),
157
+ chainId: z.number().int(),
158
+ verifyingContract: ethAddress.optional(),
159
+ salt: z.string().optional(),
160
+ }),
161
+ types: z.record(z.string(), z.array(z.object({ name: z.string(), type: z.string() }))),
162
+ primaryType: z.string(),
163
+ message: z.record(z.string(), z.unknown()),
164
+ });
165
+ export const SettlementOptionSchema = z.object({
166
+ type: settlementType,
167
+ available: z.boolean(),
168
+ reason: z.string().optional(),
169
+ });
170
+ export const ClaimInfoSchema = z.object({
171
+ factory_address: ethAddress,
172
+ salt: z.number().int(),
173
+ });
174
+ /**
175
+ * A fee layer AMOUNT in token base units: digits only, no sign, no decimal
176
+ * point, no exponent, no `0x`.
177
+ *
178
+ * A plain `z.string()` would admit `""`, `"0.5"`, `"1.2e3"` and `"-5"`. Every
179
+ * consumer immediately does `BigInt()` on these to reconcile the layers against
180
+ * `total_fee`, and `BigInt` throws on three of those four and silently returns
181
+ * `0n` on the empty string -- so the loose type turns an upstream formatting
182
+ * change into a 500 on every quote for the route (nothing wraps the
183
+ * reconciliation in a try/catch). The producer (Riddler `fee_policy.ex`,
184
+ * `calculate_layered_fee`) emits `Integer.to_string` of a non-negative integer
185
+ * for all five, so this narrowing describes what is actually served rather
186
+ * than restricting it.
187
+ *
188
+ * Deliberately NOT applied to `gas_floor.usd` or `total_usd`: those are dollar
189
+ * figures and are legitimately decimal ("0.022663").
190
+ */
191
+ const feeLayerBaseUnits = z
192
+ .string()
193
+ .regex(/^\d+$/)
194
+ .describe("Fee layer amount in token base units (integer, no decimal point). " +
195
+ "Denominated in the token named by the response's `fee_denomination`.");
196
+ /**
197
+ * Itemized fee breakdown (economics.md Part 1). snake_case wire shape the worker
198
+ * emits and the frontend renders against. Layers are three additive bps lines on
199
+ * a never-discounted cost floor (solver + gas), plus venue and routing.
200
+ *
201
+ * The layer AMOUNTS carry no denomination of their own. The token they are
202
+ * measured in is named by `fee_denomination` on the enclosing quote response.
203
+ */
204
+ export const FeeBreakdownSchema = z.object({
205
+ // The SOLVER's view of the caller's tier, under which IT computed the layer
206
+ // amounts below. NOT the authority on the caller's tier: Xochi resolves that
207
+ // from D1 (`user_tiers`) and the client-supplied `trust_score` is never
208
+ // trusted. The two can legitimately disagree for a quote or two after an
209
+ // upgrade, because the solver's copy of the score lags D1's. Render the
210
+ // caller's tier from the Xochi-side value; read this one only as the label
211
+ // that explains these particular numbers.
212
+ tier: z
213
+ .enum(["standard", "trusted", "verified", "premium", "institutional"])
214
+ .describe("The SOLVER's view of the tier, and the schedule it priced these layer " +
215
+ "amounts under. Not the authority on the caller's tier -- Xochi resolves " +
216
+ "that from its own records, and the solver's copy of the trust score can " +
217
+ "lag a fresh upgrade by a quote or two. Use it to explain these numbers, " +
218
+ "never to display the user's tier."),
219
+ asset_class: z.enum(["stable", "volatile"]),
220
+ gas_floor: z.object({ amount: z.string(), usd: z.string() }),
221
+ solver_fee_bps: z.number(),
222
+ solver_fee: feeLayerBaseUnits,
223
+ price_impact_bps: z.number(),
224
+ price_impact: feeLayerBaseUnits,
225
+ venue_fee_bps: z.number(),
226
+ venue_fee: feeLayerBaseUnits,
227
+ routing_fee_bps: z.number(),
228
+ routing_fee: feeLayerBaseUnits,
229
+ total_bps: z.number(),
230
+ total_fee: feeLayerBaseUnits,
231
+ total_usd: z.string(),
232
+ surplus_share_pct: z.number(),
233
+ // Present when a promo code was in play on the quote (xochi#584): which
234
+ // discount priced it and how much it took off the Standard venue + routing
235
+ // layers. Emitted by the solver, which priced it; the worker reads `source`
236
+ // to decide whether the code was applied rather than assuming it was.
237
+ discount: z
238
+ .object({
239
+ source: z
240
+ .enum(["tier", "promo"])
241
+ .describe("Which discount priced this quote: the trust tier's or the promo code's. They never stack: discount = max(tier, min(promo, Institutional discount))."),
242
+ bps: z
243
+ .number()
244
+ .int()
245
+ .nonnegative()
246
+ .describe("Basis points taken off the Standard venue + routing layers for this asset class. Never off the solver spread or the gas floor."),
247
+ amount: feeLayerBaseUnits,
248
+ })
249
+ .strict()
250
+ .optional()
251
+ .describe("The applied discount as a labelled line INSIDE `total_fee`: `venue_fee` and `routing_fee` are already net of it, so it is never subtracted again. Render it as e.g. `Promo LAUNCH10 -0.05%`. Absent when no promo code was sent."),
252
+ });
253
+ /**
254
+ * Which token the `fee_breakdown` layer amounts are measured in. See the
255
+ * `fee_denomination` field on {@link QuoteResponseSchema}.
256
+ */
257
+ export const FeeDenominationSchema = z.enum(["origin", "destination"]);
258
+ /**
259
+ * Canonical quote response: the Xochi worker's `/api/intent/quote` output and
260
+ * the pinned agent contract. snake_case envelope; the `eip712` value is
261
+ * verbatim EIP-712 typed data. When `can_solve` is true, `to_amount`,
262
+ * `payment_method`, and `eip712` are present. Mirrors `QuoteResponse` in
263
+ * public/openapi.yaml -- keep all three in lockstep.
264
+ */
265
+ export const QuoteResponseSchema = z
266
+ .object({
267
+ success: z.boolean(),
268
+ intent_id: z.string(),
269
+ quote_id: z.string(),
270
+ can_solve: z.boolean(),
271
+ /** Non-executable price exploration: never persisted and carries no signing material. */
272
+ preview: z.boolean().optional(),
273
+ to_amount: z.string().optional(),
274
+ min_to_amount: z.string().optional(),
275
+ xochi_fee: z.string().optional(),
276
+ xochi_fee_rate: z.string().optional(),
277
+ cost_bps: z
278
+ .number()
279
+ .nonnegative()
280
+ .optional()
281
+ .describe("All-in cost of this quote in basis points, from the same fixed-cost " +
282
+ "guard that refuses an uneconomical order, so the displayed price and " +
283
+ "the refusal threshold cannot disagree. Unlike xochi_fee_rate (a flat " +
284
+ "schedule rate) this moves with route and size: cost is " +
285
+ "max(gas floor of the destination chain, the fee rate), so it rises " +
286
+ "as the order shrinks. BOUNDED BY THE GUARD: an order whose cost " +
287
+ "exceeds the ceiling is declined with error_category " +
288
+ "'amount_too_small' and carries no cost_bps at all, so every served " +
289
+ "value sits at or below the ceiling (150 bps at MAX_COST_RATIO 0.015). " +
290
+ "Do not model a range above it. Omitted when the origin token has no " +
291
+ "dollar value on the worker and the guard could not run -- absent " +
292
+ "means unknown, never zero. Unrounded: below half a basis point the " +
293
+ "distinction between a free fill and a merely cheap one still matters, " +
294
+ "and rounding server-side destroys it."),
295
+ // Itemized layer split; optional until the worker/Riddler emit it.
296
+ fee_breakdown: FeeBreakdownSchema.optional(),
297
+ // Which token `fee_breakdown`'s amounts are measured in. Carried on the wire
298
+ // rather than inferred from the MERE PRESENCE of fee_breakdown, which would
299
+ // rest on an undocumented convention in another repo (Riddler's
300
+ // fee_policy.ex computes every layer off `input_amount`): the xochi#410
301
+ // failure class, a cross-repo fact with no runtime evidence in this one.
302
+ fee_denomination: FeeDenominationSchema.optional().describe("Which token the `fee_breakdown` layer AMOUNTS are denominated in. " +
303
+ "'origin' means `from_token` base units, 'destination' means `to_token` " +
304
+ "base units. Formatting a fee with the wrong token's decimals and symbol " +
305
+ "misstates it by the exchange rate, and on a USDC->USDC route (6 decimals " +
306
+ "both sides) the mistake is invisible -- which is why this is stated on " +
307
+ "the wire instead of inferred from the presence of `fee_breakdown`. " +
308
+ "PROVENANCE: the solver does not emit this field yet, so today Xochi " +
309
+ "asserts it from the solver's documented convention (every layer computed " +
310
+ "off the input amount, hence 'origin') rather than reporting a value the " +
311
+ "solver stated. It is forwarded verbatim once the solver does send one. " +
312
+ "Treat it as Xochi's best statement of the denomination, not as the fee " +
313
+ "computer's own declaration. ABSENT MEANS UNKNOWN, never a default: when " +
314
+ "there is no `fee_breakdown` the field is omitted entirely."),
315
+ // Referral attribution for the routing layer (xochi#583). Xochi-owned and
316
+ // a sibling of `fee_breakdown` rather than inside it: the breakdown is the
317
+ // solver's itemization and may be absent, while the routing line exists on
318
+ // every quote (the client derives one without a breakdown).
319
+ referral: QuoteReferralSchema.optional(),
320
+ referral_note: z
321
+ .literal(REFERRAL_NOT_APPLIED_NOTE)
322
+ .optional()
323
+ .describe("Present when a `ref` was sent and not applied. Deliberately one fixed text for every reason (unknown, expired, revoked, not valid for this key, a key that does not accept referrals, self-referral), so the response does not reveal whether a code exists. The quote is otherwise identical to one sent without `ref`."),
324
+ // Promo code outcome (xochi#584). Xochi-owned siblings of `fee_breakdown`:
325
+ // the breakdown itemizes the discount (`fee_breakdown.discount`), these say
326
+ // whether the code the caller sent is the thing that priced it.
327
+ promo: QuotePromoSchema.optional(),
328
+ promo_note: z
329
+ .enum([PROMO_NOT_APPLIED_NOTE, PROMO_TIER_BETTER_NOTE])
330
+ .optional()
331
+ .describe("Inline message for a `promo` that did not price this quote. The quote is still served, priced as if no code had been sent. `" +
332
+ PROMO_NOT_APPLIED_NOTE +
333
+ "` is deliberately one fixed text for every reason (unknown, expired, revoked, used up, this wallet's limit reached, not accepted under this publishable key), so the response does not reveal whether a code exists. `" +
334
+ PROMO_TIER_BETTER_NOTE +
335
+ "` means the code is valid but the trust-tier discount is at least as large; it is not consumed."),
336
+ estimated_gas_cost: z.string().optional(),
337
+ stream_topic: z
338
+ .string()
339
+ .regex(/^[A-Za-z0-9_-]{16,64}$/)
340
+ .optional()
341
+ .describe("Subscribe capability for this intent's live status and fill-race " +
342
+ "stream, at `GET /api/stream/<stream_topic>` (WebSocket upgrade). " +
343
+ "TREAT IT AS A BEARER TOKEN: possession is the whole authorization, " +
344
+ "there is no second check, and the relay replays its recent event " +
345
+ "buffer to every new subscriber. It is served exactly once, in this " +
346
+ "response, and is never returned by a read endpoint. Keep it out of " +
347
+ "logs and out of URLs you share. It is deliberately NOT the " +
348
+ "`intent_id` beside it: that value is public by construction (it " +
349
+ "appears in status paths and in server logs), so a stream keyed on it " +
350
+ "would be readable by anyone who has ever seen one. Absent on an " +
351
+ "intent quoted before this field existed, which means no stream is " +
352
+ "available; fall back to polling `GET /api/intent/:id/status`, which " +
353
+ "is the durable path in every case."),
354
+ expiry: z.string(),
355
+ gasless: z.boolean().optional(),
356
+ gasless_fee: z.string().optional(),
357
+ payment_method: paymentMethod.optional(),
358
+ route: z.record(z.string(), z.unknown()).optional(),
359
+ eip712: Eip712TypedDataSchema.optional(),
360
+ // Origin-pull authorization: a second EIP-712 the user signs so the solver
361
+ // can collect origin funds before settling. ReceiveWithAuthorization (USDC
362
+ // domain) for erc3009, PermitWitnessTransferFrom for permit2. Absent for
363
+ // non-pulling methods and the mock path.
364
+ pull_authorization: Eip712TypedDataSchema.optional(),
365
+ // Non-EVM origin (Tron): the per-intent address the user manually sends TRC-20
366
+ // to. Mutually exclusive with pull_authorization (a deposit route has no
367
+ // typed-data pull). Base58check -- never lowercased.
368
+ deposit_address: z.string().optional(),
369
+ // Solver attestation binding the deposit_address to this intent. The client
370
+ // verifies the signature against a pinned signer BEFORE displaying the address
371
+ // (fail-closed); an absent/invalid attestation makes the route unavailable.
372
+ deposit_attestation: z.string().optional(),
373
+ // Deposit-acceptance deadline (unix seconds) for a deposit route -- decoupled
374
+ // from the shorter quote `expiry` so a manual TRC-20 transfer has time to land.
375
+ // Drives the deposit countdown.
376
+ deposit_deadline: z.number().int().optional(),
377
+ // Echo of the requested destination recipient (non-EVM/Tron routes). These
378
+ // routes carry no wallet signature, so recipient integrity rides on this
379
+ // authenticated channel: the client asserts the solvable quote echoes exactly
380
+ // the address it asked for and fail-closes otherwise (#113). Base58 is
381
+ // case-sensitive -- passed through verbatim, never lowercased.
382
+ recipient_address: recipientAddress.optional(),
383
+ settlement_options: z.array(SettlementOptionSchema).optional(),
384
+ claim_info: ClaimInfoSchema.optional(),
385
+ // Execute replay-dedup nonce the GET alias injects (absent from the POST
386
+ // envelope) so a third-party ACP agent need not do the pull-nonce bit math
387
+ // itself. Derived from the pull-authorization nonce (see deriveReplayNonce).
388
+ // Optional: the POST omits it, and the GET omits it too on a pull-less route.
389
+ nonce: z.number().int().min(0).optional(),
390
+ error: z.string().optional(),
391
+ // Present on the unsolvable-200 branch: `error_category` is a stable machine
392
+ // key for the reject class, `detail` the solver's raw reason. Absent on
393
+ // solvable quotes.
394
+ //
395
+ // Enumerated so the generated spec documents the vocabulary agents branch on,
396
+ // and so adding a category without declaring it fails the contract tests.
397
+ // Four are worker-side fail-closed guards; the rest come from
398
+ // `classifyDecline` (decline.ts), whose returns are a closed set of literals
399
+ // with `unknown` as the catch-all.
400
+ // Documented, deliberately NOT enumerated. The worker owns four of these,
401
+ // but the rest come from `classifyDecline` (decline.ts) over solver reasons,
402
+ // and the contract test below asserts an unlisted value still parses -- this
403
+ // side of the vocabulary is intentionally open, so a new upstream reason must
404
+ // not start failing every client that validates.
405
+ error_category: z
406
+ .string()
407
+ .optional()
408
+ .describe("Stable machine key for a declined quote. Branch on this rather than on `error`, whose prose may change. Known values: gas_floor_unpriced (transient, retry), origin_pull_amount_mismatch, served_payout_mismatch, settlement_type_unavailable, stealth_factory_untrusted, refund_address_invalid, amount_too_large, amount_too_small, no_liquidity, route_unsupported, invalid_request (the request shape was rejected, not the route -- see `detail` for the offending field), auth, unknown. Treat an unrecognized value as a non-retryable decline."),
409
+ detail: z.string().optional(),
410
+ // Present when `error_category` is `amount_too_small` because the route's
411
+ // FIXED settlement cost consumed too much of the order: the smallest order
412
+ // (USD) that clears the threshold at the current cost. Lets a caller retry
413
+ // with a workable amount instead of bisecting for one. Not a protocol
414
+ // constant -- it moves with the corridor's cost, so read it per quote.
415
+ minimum_amount_usd: z
416
+ .number()
417
+ .positive()
418
+ .optional()
419
+ .describe("Smallest order in USD you should send on this route. THIS is the number to retry with; it already accounts for both the solver's floor and the fixed-cost threshold, so it is >= `min_fillable_usd`. Present on an `amount_too_small` decline. Varies with the corridor's current cost, so do not cache it."),
420
+ // The solver's own live bounds, passed through on a bounds decline. Both
421
+ // are served, so an undeclared one makes the strict schema reject a
422
+ // legitimate decline and leaves it out of the generated spec (#754).
423
+ min_fillable_usd: z
424
+ .number()
425
+ .positive()
426
+ .optional()
427
+ .describe("The solver's live per-corridor floor: the smallest order it will fill at all, which is its own cost to fill. Reported for transparency. Do NOT retry with this figure -- an order at exactly the solver's cost delivers almost nothing. Use `minimum_amount_usd`."),
428
+ max_order_usd: z
429
+ .number()
430
+ .positive()
431
+ .optional()
432
+ .describe("The solver's live per-trade ceiling in USD. Present on an `amount_too_large` decline."),
433
+ })
434
+ .strict();
435
+ /**
436
+ * Length of an intent read capability (`read_token` on the execute response,
437
+ * xochi#563): 32 random bytes as unpadded base64url.
438
+ */
439
+ export const READ_TOKEN_LENGTH = 43;
440
+ /** Shape of an intent read capability: exactly `READ_TOKEN_LENGTH` base64url characters. */
441
+ export const READ_TOKEN_PATTERN = new RegExp(`^[A-Za-z0-9_-]{${READ_TOKEN_LENGTH}}$`);
442
+ /**
443
+ * The request header that carries a `read_token` on `GET /api/intent/:id` and
444
+ * `GET /api/intent/:id/status`. A header and never a query parameter: the token
445
+ * must not land in URLs, logs or referrers.
446
+ */
447
+ export const READ_TOKEN_HEADER = "X-Xochi-Read-Token";
448
+ /** Mirrors `ExecuteResponse` in public/openapi.yaml. */
449
+ export const ExecuteResponseSchema = z
450
+ .object({
451
+ success: z.boolean(),
452
+ intent_id: z.string(),
453
+ status: z.string(),
454
+ tx_hash: z.string().optional(),
455
+ // Per-leg hashes for cross-chain: the origin/deposit leg is `tx_hash`, the
456
+ // destination fill is `receiving_tx_hash` (arrives later via status/WS).
457
+ bridge_tx_hash: z.string().optional(),
458
+ receiving_tx_hash: z.string().optional(),
459
+ // Public destination fill receipt (Riddler #632) + its chain, when a same-chain
460
+ // or fast fill resolves inline; usually arrives later via the status poll.
461
+ settlement_tx_hash: z.string().optional(),
462
+ settlement_chain_id: z.number().int().optional(),
463
+ // In-doubt: the solver may have moved funds but did not confirm. The intent
464
+ // stays non-terminal; surface this so the client shows "still confirming"
465
+ // rather than a success or a failure.
466
+ reconciling: z.boolean().optional(),
467
+ note_commitment: z.string().optional(),
468
+ stealth_address: z.string().optional(),
469
+ ephemeral_pub_key: z.string().optional(),
470
+ view_tag: z.number().int().optional(),
471
+ error: z.string().optional(),
472
+ substatus: z
473
+ .literal(NOT_EXECUTED_SUBSTATUS)
474
+ .optional()
475
+ .describe("`not_executed` on a `failed` where nothing moved, served by the execute 502 of the worker's own finishes (the resend budget's, a not-accepted refusal whose un-claim's write failed, no solver configured, a solver refusal carrying `details.funds_moved: false`) and echoed from the row by the lost-race 502 and the in-doubt 200: safe to re-quote. Absent on any other failure. Every source: docs/contracts/xochi-intent-api.md, \"The worker's not_executed marker\"."),
476
+ // Per-intent read capability (xochi#563): 32 random bytes, base64url, no
477
+ // padding. Served once, by the execute that admits the intent; present it as
478
+ // `X-Xochi-Read-Token` on `GET /api/intent/:id[/status]` to read the owner's
479
+ // view of a stealth intent; a shielded intent's token read gets the
480
+ // status-only view (SPEC.md §5.1). Older servers omit it. The one declared
481
+ // field that never fails the body: a malformed token is dropped, because this
482
+ // body reports an execute that may already have moved funds, and failing it
483
+ // would turn a settled swap into an in-doubt one over a privacy-only field.
484
+ // The describe is xochi's (xochi#565, `0317e84`) verbatim.
485
+ read_token: z
486
+ .string()
487
+ .regex(READ_TOKEN_PATTERN)
488
+ .optional()
489
+ .catch(undefined)
490
+ .describe("Per-intent read capability (xochi#563): 32 random bytes, base64url without padding. Returned to the request that claimed the intent for execution, on every answer where funds may have moved: the 200, the 502 of an unmarked solver refusal or failure this request wrote, a 502 whose status another writer decided, and the outcome unknown after the claim. Never on a `not_executed` answer, an un-claim back to `quoted` (the not-accepted 503, the in-doubt 200 with `status: \"quoted\"`) or a retried execute. Store it with the intent id and send it as the `X-Xochi-Read-Token` header on `GET /api/intent/{id}` and `/status` to get the owner's full view of a stealth intent without a wallet session. On a shielded intent it reads only the status-only view (legs_withheld), like an anonymous read; only a SIWE session for the intent's wallet reads that full record. A bearer secret: never log it or put it in a URL. The server stores only its SHA-256, so it cannot be re-issued; signing in with the intent's wallet (SIWE) reads the same view."),
491
+ })
492
+ .strict();
493
+ /**
494
+ * Automatic resends of one execute after its not-accepted answer. Mirrors
495
+ * `MAX_EXECUTE_RESENDS` in xochi's `@xochi/shared` `schemas.ts`, the budget
496
+ * the Worker enforces too: the refusal that follows this many un-claims
497
+ * finishes the intent `failed` instead.
498
+ */
499
+ export const MAX_EXECUTE_RESENDS = 3;
500
+ /**
501
+ * A resend must reach the Worker at least this many seconds before the
502
+ * earliest deadline the signed request carries. Mirrors
503
+ * `EXECUTE_RESEND_MARGIN_SECS` in xochi's `@xochi/shared` `schemas.ts`, the
504
+ * margin the Worker applies before it un-claims.
505
+ */
506
+ export const EXECUTE_RESEND_MARGIN_SECS = 5;
507
+ /**
508
+ * Seconds past an unexecuted intent's signed deadline before the Worker ends
509
+ * it: a `quoted` market row (never claimed, or every claim undone) that an
510
+ * intent read (`GET /api/intent/:id` or the status poll, never the stream)
511
+ * finds this far past the `deadline` the user signed is finished `failed`
512
+ * with `NOT_EXECUTED_SUBSTATUS`, and the read serves that. Slack for clock
513
+ * skew between the Worker, the database (whose clock the claim's deadline
514
+ * check uses), the solver and the client. Measured from the signed deadline;
515
+ * the quote sweep's own 60 s is measured from the Worker's 30 s quote expiry
516
+ * and usually ends a touched row first (README "Fund safety"). Mirrors
517
+ * `QUOTED_EXPIRY_GRACE_SECS` in xochi's `@xochi/shared` `schemas.ts`. The
518
+ * machine polls an execute at least this long past the quote's deadline
519
+ * (`unexecutedFinishAt`).
520
+ */
521
+ export const QUOTED_EXPIRY_GRACE_SECS = 60;