@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.
- package/CHANGELOG.md +29 -0
- package/README.md +4 -3
- package/dist/cjs/machine/index.d.ts +1 -1
- package/dist/cjs/machine/index.js +2 -1
- package/dist/cjs/machine/machine.d.ts +4 -0
- package/dist/cjs/machine/machine.js +57 -31
- package/dist/cjs/machine/state.d.ts +4 -3
- package/dist/cjs/machine/state.js +1 -1
- package/dist/cjs/shared/index.d.ts +1 -0
- package/dist/cjs/shared/index.js +1 -0
- package/dist/cjs/shared/schemas.js +4 -4
- package/dist/cjs/shared/shieldedPreview.d.ts +841 -0
- package/dist/cjs/shared/shieldedPreview.js +128 -0
- package/dist/cjs/transport/client.d.ts +4 -3
- package/dist/cjs/transport/client.js +27 -22
- package/dist/esm/machine/index.d.ts +1 -1
- package/dist/esm/machine/index.js +1 -1
- package/dist/esm/machine/machine.d.ts +4 -0
- package/dist/esm/machine/machine.js +55 -29
- package/dist/esm/machine/state.d.ts +4 -3
- package/dist/esm/machine/state.js +1 -1
- package/dist/esm/shared/index.d.ts +1 -0
- package/dist/esm/shared/index.js +1 -0
- package/dist/esm/shared/schemas.js +4 -4
- package/dist/esm/shared/shieldedPreview.d.ts +841 -0
- package/dist/esm/shared/shieldedPreview.js +124 -0
- package/dist/esm/transport/client.d.ts +4 -3
- package/dist/esm/transport/client.js +27 -22
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Riddler's shielded terms preview wire (`POST /xochi/shielded/preview`,
|
|
4
|
+
* Riddler#1296): the typed request and response shapes and a decoder.
|
|
5
|
+
*
|
|
6
|
+
* There is NO transport method: the preview needs a `quote`-scoped Riddler
|
|
7
|
+
* credential, so only the Xochi Worker calls it, and the Worker proxy route is
|
|
8
|
+
* a follow-up owned by xochi#635. These shapes exist so that proxy and its
|
|
9
|
+
* consumers decode the solver's answer with the same schema the solver
|
|
10
|
+
* publishes (`packages/spec/wire/openapi.json`, `ShieldedPreviewResponse`).
|
|
11
|
+
*
|
|
12
|
+
* The preview is NOT a signing payload: `preview_digest` is the EIP-712 digest
|
|
13
|
+
* of `ShieldedPreviewV1` under the `Xochi Shielded Preview` domain, which no
|
|
14
|
+
* `ShieldedTermsV1` signature verifies against (`@xochi/spec`
|
|
15
|
+
* `conformance/shielded-terms.json`, PROPOSED). `executable` is always false.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.ShieldedPreviewResponseSchema = exports.ShieldedPreviewSchema = exports.ShieldedFeeBreakdownSchema = exports.ShieldedCorridorSchema = exports.ShieldedNetworkIdentitySchema = exports.aztecValue = void 0;
|
|
19
|
+
exports.decodeShieldedPreview = decodeShieldedPreview;
|
|
20
|
+
const zod_1 = require("zod");
|
|
21
|
+
const schemas_js_1 = require("./schemas.js");
|
|
22
|
+
/** 32-byte hex (`0x` + 64). */
|
|
23
|
+
const bytes32 = zod_1.z.string().regex(/^0x[0-9a-fA-F]{64}$/);
|
|
24
|
+
/**
|
|
25
|
+
* A 32-byte Aztec address or field element. The decoder checks the shape only;
|
|
26
|
+
* the solver has already refused zero and values at or above the BN254 modulus.
|
|
27
|
+
* A 20-byte EVM address never fits.
|
|
28
|
+
*/
|
|
29
|
+
exports.aztecValue = bytes32;
|
|
30
|
+
/** Canonical unsigned decimal string (uint256 members). */
|
|
31
|
+
const uintString = zod_1.z.string().regex(/^(0|[1-9][0-9]*)$/);
|
|
32
|
+
/** Canonical positive base-unit amount. */
|
|
33
|
+
const baseUnits = zod_1.z.string().regex(/^[1-9][0-9]*$/);
|
|
34
|
+
exports.ShieldedNetworkIdentitySchema = zod_1.z.object({
|
|
35
|
+
aztec_chain_id: zod_1.z.string().regex(/^[1-9][0-9]*$/),
|
|
36
|
+
protocol_version: zod_1.z.string().regex(/^[1-9][0-9]*$/),
|
|
37
|
+
rollup_address: schemas_js_1.ethAddress,
|
|
38
|
+
l1_chain_id: zod_1.z.string().regex(/^[1-9][0-9]*$/),
|
|
39
|
+
genesis_or_network_digest: bytes32,
|
|
40
|
+
});
|
|
41
|
+
exports.ShieldedCorridorSchema = zod_1.z.object({
|
|
42
|
+
corridor_id: bytes32,
|
|
43
|
+
revision: zod_1.z.number().int().min(1).max(4294967295),
|
|
44
|
+
manifest_digest: bytes32,
|
|
45
|
+
});
|
|
46
|
+
exports.ShieldedFeeBreakdownSchema = zod_1.z.object({
|
|
47
|
+
gas_floor: uintString,
|
|
48
|
+
solver_fee_bps: zod_1.z.number().int().min(0).max(10000),
|
|
49
|
+
solver_fee: uintString,
|
|
50
|
+
price_impact_bps: zod_1.z.number().int().min(0).max(10000),
|
|
51
|
+
price_impact: uintString,
|
|
52
|
+
venue_fee_bps: zod_1.z.number().int().min(0).max(10000),
|
|
53
|
+
venue_fee: uintString,
|
|
54
|
+
routing_fee_bps: zod_1.z.number().int().min(0).max(10000),
|
|
55
|
+
routing_fee: uintString,
|
|
56
|
+
total_bps: zod_1.z.number().int().min(0).max(40000),
|
|
57
|
+
total_fee: uintString,
|
|
58
|
+
});
|
|
59
|
+
/** `ShieldedPreviewV1` in snake_case: the terms minus the per-execution members. */
|
|
60
|
+
exports.ShieldedPreviewSchema = zod_1.z.object({
|
|
61
|
+
protocol_version: zod_1.z.literal("xochi.shielded.preview.v1"),
|
|
62
|
+
environment: zod_1.z.string().min(1),
|
|
63
|
+
source: zod_1.z.object({
|
|
64
|
+
chain_id: zod_1.z.number().int().positive(),
|
|
65
|
+
token_address: schemas_js_1.ethAddress,
|
|
66
|
+
wallet: schemas_js_1.ethAddress,
|
|
67
|
+
collector: schemas_js_1.ethAddress,
|
|
68
|
+
amount: baseUnits,
|
|
69
|
+
}),
|
|
70
|
+
destination: zod_1.z.object({
|
|
71
|
+
settlement_type: zod_1.z.literal("shielded"),
|
|
72
|
+
privacy_level: zod_1.z.literal("private"),
|
|
73
|
+
disclosure_profile: zod_1.z.literal("recipient-private-public-amount-v1"),
|
|
74
|
+
rail: zod_1.z.literal("a_tracer"),
|
|
75
|
+
network: exports.ShieldedNetworkIdentitySchema,
|
|
76
|
+
corridor: exports.ShieldedCorridorSchema,
|
|
77
|
+
asset_id: zod_1.z.string().min(1),
|
|
78
|
+
token_address: exports.aztecValue,
|
|
79
|
+
recipient_binding_id: zod_1.z.string().min(1),
|
|
80
|
+
recipient_descriptor_digest: bytes32,
|
|
81
|
+
recipient_address: exports.aztecValue,
|
|
82
|
+
amount: baseUnits,
|
|
83
|
+
fee_payer: exports.aztecValue,
|
|
84
|
+
}),
|
|
85
|
+
fee_breakdown: exports.ShieldedFeeBreakdownSchema,
|
|
86
|
+
refund_address: schemas_js_1.ethAddress,
|
|
87
|
+
refund_amount: baseUnits,
|
|
88
|
+
collect_before: zod_1.z.number().int().nonnegative(),
|
|
89
|
+
settlement_service_deadline: zod_1.z.number().int().nonnegative(),
|
|
90
|
+
recipient_binding_digest: bytes32,
|
|
91
|
+
policy_revision: zod_1.z.number().int().nonnegative(),
|
|
92
|
+
});
|
|
93
|
+
exports.ShieldedPreviewResponseSchema = zod_1.z.object({
|
|
94
|
+
executable: zod_1.z.literal(false),
|
|
95
|
+
preview_version: zod_1.z.literal("xochi.shielded.preview.v1"),
|
|
96
|
+
preview_digest: bytes32,
|
|
97
|
+
recipient_binding_digest: bytes32,
|
|
98
|
+
domain: zod_1.z.object({
|
|
99
|
+
name: zod_1.z.literal("Xochi Shielded Preview"),
|
|
100
|
+
version: zod_1.z.literal("1"),
|
|
101
|
+
chain_id: zod_1.z.number().int().positive(),
|
|
102
|
+
salt: bytes32,
|
|
103
|
+
}),
|
|
104
|
+
preview: exports.ShieldedPreviewSchema,
|
|
105
|
+
manifest: zod_1.z.object({
|
|
106
|
+
manifest_digest: bytes32,
|
|
107
|
+
aztec_sdk_version: zod_1.z.string().min(1),
|
|
108
|
+
recipient_account_class_id: exports.aztecValue,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Decode a preview response. Throws (zod) on any shape the solver does not
|
|
113
|
+
* publish, including an executable or terms-shaped answer, and on a fee
|
|
114
|
+
* breakdown whose totals disagree: `total_fee` must be `max(gas_floor, layers)` (the gas
|
|
115
|
+
* floor is a minimum fee, as in FeePolicy) and `total_bps` the four rates.
|
|
116
|
+
*/
|
|
117
|
+
function decodeShieldedPreview(body) {
|
|
118
|
+
const decoded = exports.ShieldedPreviewResponseSchema.parse(body);
|
|
119
|
+
const f = decoded.preview.fee_breakdown;
|
|
120
|
+
const layers = BigInt(f.solver_fee) + BigInt(f.price_impact) + BigInt(f.venue_fee) + BigInt(f.routing_fee);
|
|
121
|
+
const gasFloor = BigInt(f.gas_floor);
|
|
122
|
+
const totalFee = layers > gasFloor ? layers : gasFloor;
|
|
123
|
+
const totalBps = f.solver_fee_bps + f.price_impact_bps + f.venue_fee_bps + f.routing_fee_bps;
|
|
124
|
+
if (BigInt(f.total_fee) !== totalFee || f.total_bps !== totalBps) {
|
|
125
|
+
throw new Error("shielded preview fee_breakdown totals disagree with max(gas_floor, layers)");
|
|
126
|
+
}
|
|
127
|
+
return decoded;
|
|
128
|
+
}
|
|
@@ -38,7 +38,7 @@ export interface RetryPolicy {
|
|
|
38
38
|
maxAttempts: number;
|
|
39
39
|
/** First exponential backoff step. */
|
|
40
40
|
baseDelayMs: number;
|
|
41
|
-
/** Clamp on
|
|
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.
|
|
123
|
-
*
|
|
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
|
/**
|
|
@@ -138,11 +138,19 @@ function isNotAcceptedExecuteBody(status, body, intentId) {
|
|
|
138
138
|
* MAX_RETRY_WAIT_SEC.
|
|
139
139
|
*/
|
|
140
140
|
function notAcceptedRetryAt(headers, retryAfter) {
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const wait = Number.isFinite(headerDelta) && headerDelta > retryAfter ? headerDelta : retryAfter;
|
|
141
|
+
const headerDelta = retryAfterDeltaSeconds(headers.get("Retry-After"));
|
|
142
|
+
const wait = headerDelta !== null && headerDelta > retryAfter ? headerDelta : retryAfter;
|
|
144
143
|
return Math.ceil((Date.now() + Math.min(wait, exports.MAX_RETRY_WAIT_SEC) * 1000) / 1000);
|
|
145
144
|
}
|
|
145
|
+
/** Retry-After's delta-seconds grammar: optional HTTP whitespace around 1*DIGIT. */
|
|
146
|
+
function retryAfterDeltaSeconds(headerValue) {
|
|
147
|
+
const value = headerValue?.trim();
|
|
148
|
+
if (!value || !/^\d+$/.test(value))
|
|
149
|
+
return null;
|
|
150
|
+
const seconds = Number(value);
|
|
151
|
+
// Syntactically valid integers beyond Number's range still take the 600 s cap.
|
|
152
|
+
return Number.isFinite(seconds) ? seconds : Number.MAX_SAFE_INTEGER;
|
|
153
|
+
}
|
|
146
154
|
/**
|
|
147
155
|
* Read tokens held for the errors `execute` rejected with, keyed by the error
|
|
148
156
|
* so the token is never reachable from the error itself (a crash reporter or a
|
|
@@ -647,14 +655,13 @@ function readTokenFromPrefix(prefix, intentId) {
|
|
|
647
655
|
/**
|
|
648
656
|
* Parse a Retry-After header (our API sends integer seconds) into milliseconds,
|
|
649
657
|
* clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
|
|
650
|
-
* client.
|
|
651
|
-
*
|
|
658
|
+
* client. The default safety ceiling preserves server-directed waits up to
|
|
659
|
+
* MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
|
|
660
|
+
* absent/unparseable so the caller falls back to its own exponential backoff.
|
|
652
661
|
*/
|
|
653
|
-
function parseRetryAfterMs(headerValue, maxDelayMs = exports.
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const seconds = Number(headerValue);
|
|
657
|
-
if (!Number.isFinite(seconds) || seconds < 0)
|
|
662
|
+
function parseRetryAfterMs(headerValue, maxDelayMs = exports.MAX_RETRY_WAIT_SEC * 1000) {
|
|
663
|
+
const seconds = retryAfterDeltaSeconds(headerValue);
|
|
664
|
+
if (seconds === null)
|
|
658
665
|
return null;
|
|
659
666
|
return Math.min(seconds * 1000, maxDelayMs);
|
|
660
667
|
}
|
|
@@ -702,12 +709,9 @@ function resolveRetryAfterSeconds(headers, body) {
|
|
|
702
709
|
return Math.min(target, nowSec + exports.MAX_RETRY_WAIT_SEC);
|
|
703
710
|
}
|
|
704
711
|
function deriveRetryTarget(headers, body, nowSec) {
|
|
705
|
-
const
|
|
706
|
-
if (
|
|
707
|
-
|
|
708
|
-
if (Number.isFinite(delta) && delta >= 0)
|
|
709
|
-
return nowSec + delta;
|
|
710
|
-
}
|
|
712
|
+
const delta = retryAfterDeltaSeconds(headers.get("Retry-After"));
|
|
713
|
+
if (delta !== null)
|
|
714
|
+
return nowSec + delta;
|
|
711
715
|
const reset = headers.get("X-RateLimit-Reset");
|
|
712
716
|
if (reset !== null) {
|
|
713
717
|
const abs = Number(reset);
|
|
@@ -1015,7 +1019,7 @@ function createXochiClient(options) {
|
|
|
1015
1019
|
// A 503 is a transient solver blip (saturation / gas-oracle
|
|
1016
1020
|
// unavailable); pace the retry off the server's Retry-After.
|
|
1017
1021
|
if (response.status === 503) {
|
|
1018
|
-
retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After")
|
|
1022
|
+
retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
|
|
1019
1023
|
}
|
|
1020
1024
|
// A cut body does not parse, so it never carries the marker.
|
|
1021
1025
|
const record = truncated ? undefined : parseJsonObject(errorBody);
|
|
@@ -1071,7 +1075,7 @@ function createXochiClient(options) {
|
|
|
1071
1075
|
if (lastError.name === "AbortError")
|
|
1072
1076
|
break;
|
|
1073
1077
|
if (attempt < maxAttempts - 1) {
|
|
1074
|
-
const delay = retryAfterMs ?? retry.baseDelayMs * Math.pow(2, attempt);
|
|
1078
|
+
const delay = retryAfterMs ?? Math.min(retry.baseDelayMs * Math.pow(2, attempt), retry.maxDelayMs);
|
|
1075
1079
|
// A retry means the request already failed once: that is the context
|
|
1076
1080
|
// for the error below if the retries run out.
|
|
1077
1081
|
log("warn", `[xochi-client] Retry ${attempt + 1}/${maxAttempts} in ${delay}ms`);
|
|
@@ -1172,12 +1176,12 @@ function createXochiClient(options) {
|
|
|
1172
1176
|
return cached.quote;
|
|
1173
1177
|
}
|
|
1174
1178
|
const pending = pendingQuotes.get(requestHash);
|
|
1175
|
-
if (pending
|
|
1179
|
+
if (pending) {
|
|
1176
1180
|
log("debug", "[xochi-client] Deduplicating quote request");
|
|
1177
|
-
return pending
|
|
1181
|
+
return pending;
|
|
1178
1182
|
}
|
|
1179
1183
|
const promise = fetchQuote(request);
|
|
1180
|
-
pendingQuotes.set(requestHash,
|
|
1184
|
+
pendingQuotes.set(requestHash, promise);
|
|
1181
1185
|
try {
|
|
1182
1186
|
const quote = await promise;
|
|
1183
1187
|
// Cache successful quotes -- EXCEPT pull-bearing ones. A quote carrying a
|
|
@@ -1190,7 +1194,8 @@ function createXochiClient(options) {
|
|
|
1190
1194
|
return quote;
|
|
1191
1195
|
}
|
|
1192
1196
|
finally {
|
|
1193
|
-
pendingQuotes.
|
|
1197
|
+
if (pendingQuotes.get(requestHash) === promise)
|
|
1198
|
+
pendingQuotes.delete(requestHash);
|
|
1194
1199
|
}
|
|
1195
1200
|
},
|
|
1196
1201
|
invalidateQuote(request) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Plus the wire decoders and the fund-safety gates the machine runs, exported
|
|
8
8
|
* so the widget and headless integrators share one implementation of each.
|
|
9
9
|
*/
|
|
10
|
-
export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, type Model, type Msg, type MsgBody, type Effect, type ApiCall, type WalletCall, type TimerCall, type TimerName, type MachineEvent, type ErrorReport, type Context, type WalletContext, type Outcome, type StealthMetaAddress, type MachineConfig, type QuoteFlight, type SignFlow, type PollState, type ExecuteAttempt, } from "./machine.js";
|
|
10
|
+
export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, NOT_SENT_SWEPT_ERROR, type Model, type Msg, type MsgBody, type Effect, type ApiCall, type WalletCall, type TimerCall, type TimerName, type MachineEvent, type ErrorReport, type Context, type WalletContext, type Outcome, type StealthMetaAddress, type MachineConfig, type QuoteFlight, type SignFlow, type PollState, type ExecuteAttempt, } from "./machine.js";
|
|
11
11
|
export { view } from "./view.js";
|
|
12
12
|
export { payoutOf, costOf, settlementDisclosure, type Receipt, type Served, type PayoutSlot, type CostSlot, type QuoteSlot, type SigningSlot, type SettlementDisclosure, } from "./receipt.js";
|
|
13
13
|
export { phaseFromStatus, type SwapPhase } from "./phase.js";
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Plus the wire decoders and the fund-safety gates the machine runs, exported
|
|
8
8
|
* so the widget and headless integrators share one implementation of each.
|
|
9
9
|
*/
|
|
10
|
-
export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, } from "./machine.js";
|
|
10
|
+
export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, NOT_SENT_SWEPT_ERROR, } from "./machine.js";
|
|
11
11
|
export { view } from "./view.js";
|
|
12
12
|
export { payoutOf, costOf, settlementDisclosure, } from "./receipt.js";
|
|
13
13
|
export { phaseFromStatus } from "./phase.js";
|
|
@@ -44,6 +44,8 @@ export declare const RECONCILE_AFTER_ATTEMPTS: number;
|
|
|
44
44
|
* for a real human to review + sign, short enough to recover from a dead prompt.
|
|
45
45
|
*/
|
|
46
46
|
export declare const SIGN_PROMPT_TIMEOUT_MS = 90000;
|
|
47
|
+
/** The error of a not-sent intent the poll finds gone (`onStatus`): the Retry view. */
|
|
48
|
+
export declare const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
|
|
47
49
|
export interface StealthMetaAddress {
|
|
48
50
|
spendingPubKey: string;
|
|
49
51
|
viewingPubKey: string;
|
|
@@ -219,6 +221,8 @@ export type MsgBody = {
|
|
|
219
221
|
type: "SignRequested";
|
|
220
222
|
} | {
|
|
221
223
|
type: "PullSignRequested";
|
|
224
|
+
} | {
|
|
225
|
+
type: "CancelSignRequested";
|
|
222
226
|
} | {
|
|
223
227
|
type: "ResetRequested";
|
|
224
228
|
} | {
|
|
@@ -59,7 +59,7 @@ const QUOTE_DEADLINE_SECS = 3600;
|
|
|
59
59
|
/** Log prefix. The host hook is still `useIntent`, and its logs are grepped for it. */
|
|
60
60
|
const LOG = "[useIntent]";
|
|
61
61
|
/** The error of a not-sent intent the poll finds gone (`onStatus`): the Retry view. */
|
|
62
|
-
const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
|
|
62
|
+
export const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
|
|
63
63
|
export function init(config, ctx, hidden = false) {
|
|
64
64
|
return {
|
|
65
65
|
config,
|
|
@@ -156,6 +156,10 @@ function step(m, msg, fx) {
|
|
|
156
156
|
return m.amount ? startQuote(m, fx, msg.now, quoteOpts(m.amount)) : m;
|
|
157
157
|
case "SignRequested":
|
|
158
158
|
return beginSign(m, fx, msg.now);
|
|
159
|
+
case "CancelSignRequested":
|
|
160
|
+
if (m.intent.status !== "signing" || !m.sign)
|
|
161
|
+
return m;
|
|
162
|
+
return transition(abandonSign(m, fx), fx, "quoted", { error: "Signature cancelled" });
|
|
159
163
|
case "PullSignRequested":
|
|
160
164
|
return beginPullSign(m, fx, msg.now);
|
|
161
165
|
case "ResetRequested":
|
|
@@ -216,10 +220,15 @@ function step(m, msg, fx) {
|
|
|
216
220
|
// Mobile throttles timers while backgrounded (a wallet deep-link
|
|
217
221
|
// round-trip), so snap the in-flight phase forward rather than wait out
|
|
218
222
|
// the interval. Off-interval: it spends no give-up budget. A pending
|
|
219
|
-
// resend reads no status; a return long past its time resends now.
|
|
223
|
+
// resend reads no status; a return long past its time resends now. A
|
|
224
|
+
// poll that exhausted its budget gets a fresh budget when the user
|
|
225
|
+
// returns instead of leaving a permanently stale receipt.
|
|
220
226
|
if (awaitingResend(next))
|
|
221
227
|
next = resendBackstop(next, fx, msg.now);
|
|
222
|
-
else if (next.poll
|
|
228
|
+
else if (next.poll?.stopped) {
|
|
229
|
+
next = { ...patch(next, { pollExhausted: false }), poll: null };
|
|
230
|
+
}
|
|
231
|
+
else if (next.poll)
|
|
223
232
|
next = pollTick(next, fx, false);
|
|
224
233
|
return next;
|
|
225
234
|
}
|
|
@@ -717,8 +726,9 @@ function onTimer(m, fx, timer, now) {
|
|
|
717
726
|
return resendBackstop(m, fx, now);
|
|
718
727
|
return m.poll && !m.poll.stopped ? pollTick(m, fx, true) : m;
|
|
719
728
|
case "signPrompt":
|
|
720
|
-
// A
|
|
721
|
-
|
|
729
|
+
// A wallet prompt that never resolved. The flow still owns the prompt;
|
|
730
|
+
// `signFlowFailed` makes any reply arriving after this timer inert.
|
|
731
|
+
return m.sign
|
|
722
732
|
? signFlowFailed(m, fx, {
|
|
723
733
|
rejected: false,
|
|
724
734
|
value: "wallet_prompt_timeout",
|
|
@@ -826,6 +836,10 @@ function beginSign(m, fx, now) {
|
|
|
826
836
|
chainId: next.ctx.wallet.chainId,
|
|
827
837
|
},
|
|
828
838
|
});
|
|
839
|
+
// A wallet request can be dismissed without rejecting (mobile deep links in
|
|
840
|
+
// particular). Bound every prompt, including signature 1, so the ceremony
|
|
841
|
+
// always returns to an actionable surface.
|
|
842
|
+
setTimer(fx, "signPrompt", SIGN_PROMPT_TIMEOUT_MS, false);
|
|
829
843
|
return { ...next, sign: { id, origin: "sign", stage: "intent", intentSignature: null } };
|
|
830
844
|
}
|
|
831
845
|
function beginPullSign(m, fx, now) {
|
|
@@ -926,8 +940,7 @@ function onSigned(m, fx, flowId, purpose, result, now) {
|
|
|
926
940
|
// open; its signature then belongs to no ceremony.
|
|
927
941
|
if (m.intent.status !== "signing")
|
|
928
942
|
return abandonSign(m, fx);
|
|
929
|
-
|
|
930
|
-
clearTimer(fx, "signPrompt");
|
|
943
|
+
clearTimer(fx, "signPrompt");
|
|
931
944
|
if (!result.ok)
|
|
932
945
|
return signFlowFailed(m, fx, result.error);
|
|
933
946
|
if (purpose === "pull") {
|
|
@@ -1210,7 +1223,7 @@ function endSignFlow(m, flow) {
|
|
|
1210
1223
|
* affordance, no prompt timer. Nothing signed so far can execute after this.
|
|
1211
1224
|
*/
|
|
1212
1225
|
function abandonSign(m, fx) {
|
|
1213
|
-
if (m.sign
|
|
1226
|
+
if (m.sign)
|
|
1214
1227
|
clearTimer(fx, "signPrompt");
|
|
1215
1228
|
if (awaitingResend(m))
|
|
1216
1229
|
clearTimer(fx, "execute");
|
|
@@ -1226,12 +1239,16 @@ function signFlowFailed(m, fx, report) {
|
|
|
1226
1239
|
const flow = m.sign;
|
|
1227
1240
|
if (!flow)
|
|
1228
1241
|
return m;
|
|
1229
|
-
|
|
1230
|
-
clearTimer(fx, "signPrompt");
|
|
1242
|
+
clearTimer(fx, "signPrompt");
|
|
1231
1243
|
const next = endSignFlow(m, flow);
|
|
1232
1244
|
if (flow.origin === "sign") {
|
|
1233
1245
|
if (report.rejected)
|
|
1234
1246
|
return transition(next, fx, "quoted", { error: "Signature rejected" });
|
|
1247
|
+
if (report.value === "wallet_prompt_timeout") {
|
|
1248
|
+
return transition(next, fx, "quoted", {
|
|
1249
|
+
error: "Couldn't reach the wallet. Press Confirm to try again.",
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1235
1252
|
log(fx, "error", `${LOG} Sign error:`, report.detail);
|
|
1236
1253
|
return transition(next, fx, "failed", { error: report.value ?? "Signing failed" });
|
|
1237
1254
|
}
|
|
@@ -1300,15 +1317,20 @@ function onStatus(m, fx, gen, attempt, result, now) {
|
|
|
1300
1317
|
if (!poll || poll.gen !== gen || poll.stopped)
|
|
1301
1318
|
return m;
|
|
1302
1319
|
const id = poll.intentId;
|
|
1320
|
+
// Replies can race (an interval read and a visibility read). Once a newer
|
|
1321
|
+
// reply has answered, an older one cannot regress lifecycle or receipt
|
|
1322
|
+
// fields, including clearing a newer not-sent verdict.
|
|
1323
|
+
if (result.ok && attempt < poll.answered)
|
|
1324
|
+
return m;
|
|
1303
1325
|
let next = m;
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
const
|
|
1326
|
+
// An `executing` intent that reads `quoted` after /execute has answered is
|
|
1327
|
+
// unclaimed. Its signed request remains executable until the deadline, so
|
|
1328
|
+
// this is "not sent yet", never "Still confirming" (whose Close invites a
|
|
1329
|
+
// second signature). Do not use a status read while /execute is outstanding:
|
|
1330
|
+
// `armPoll` delays the first read until that reply, preventing a stale
|
|
1331
|
+
// pre-acceptance `quoted` answer from winning. An intent already in doubt
|
|
1332
|
+
// stays so, as does one whose newer status reply already answered.
|
|
1333
|
+
const unsentRead = attempt >= poll.answered &&
|
|
1312
1334
|
result.ok &&
|
|
1313
1335
|
!!result.status &&
|
|
1314
1336
|
!("notFound" in result.status) &&
|
|
@@ -1316,14 +1338,14 @@ function onStatus(m, fx, gen, attempt, result, now) {
|
|
|
1316
1338
|
result.status.unmapped !== true &&
|
|
1317
1339
|
next.intent.intentId === id &&
|
|
1318
1340
|
next.intent.status === "executing" &&
|
|
1319
|
-
next.
|
|
1341
|
+
next.sign === null &&
|
|
1320
1342
|
!next.intent.reconciling;
|
|
1321
1343
|
// A throw answers nothing, so only a reply (a 404 included) moves it.
|
|
1322
1344
|
const answered = result.ok
|
|
1323
1345
|
? { ...poll, answered: Math.max(poll.answered, attempt) }
|
|
1324
1346
|
: poll;
|
|
1325
|
-
if (
|
|
1326
|
-
log(fx, "warn", `${LOG}
|
|
1347
|
+
if (unsentRead && !next.intent.notSent) {
|
|
1348
|
+
log(fx, "warn", `${LOG} Intent ${id} reads quoted after execute: not sent yet`);
|
|
1327
1349
|
next = patch(next, { notSent: true });
|
|
1328
1350
|
}
|
|
1329
1351
|
// A slow execute escalates to "Still confirming"; one not sent (`notSent`)
|
|
@@ -1405,15 +1427,15 @@ function onStatus(m, fx, gen, attempt, result, now) {
|
|
|
1405
1427
|
next = patch(next, { legsWithheld });
|
|
1406
1428
|
}
|
|
1407
1429
|
// The server owns the active-polling decision; a phase change refills the
|
|
1408
|
-
// give-up budget, so the ceiling bounds a swap that is not moving. A
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
// A
|
|
1412
|
-
//
|
|
1413
|
-
|
|
1430
|
+
// give-up budget, so the ceiling bounds a swap that is not moving. A
|
|
1431
|
+
// resumed not-sent intent has no quote from which to size the Worker's
|
|
1432
|
+
// finish, so each `quoted` read refills its budget until the Worker ends
|
|
1433
|
+
// the row. A live intent has a quote and remains bounded by its fixed
|
|
1434
|
+
// ceiling even after it enters not-sent.
|
|
1435
|
+
const resumedNotSent = unsentRead && next.intent.notSent && next.intent.quote === null;
|
|
1414
1436
|
verdict = s.terminal
|
|
1415
1437
|
? "stop"
|
|
1416
|
-
: next.intent.status !== previous ||
|
|
1438
|
+
: next.intent.status !== previous || resumedNotSent
|
|
1417
1439
|
? "progress"
|
|
1418
1440
|
: undefined;
|
|
1419
1441
|
}
|
|
@@ -1581,7 +1603,11 @@ function armExpiry(m, fx, now) {
|
|
|
1581
1603
|
function armPoll(m, fx, now) {
|
|
1582
1604
|
const { intentId, status } = m.intent;
|
|
1583
1605
|
const waiting = awaitingResend(m);
|
|
1584
|
-
|
|
1606
|
+
// The execute reply establishes whether the request was accepted. Reading
|
|
1607
|
+
// status before it answers can return the pre-acceptance `quoted` row and
|
|
1608
|
+
// falsely decide "not sent"; the reply (including a timeout) arms the poll.
|
|
1609
|
+
const executePending = m.sign?.stage === "execute" && !waiting;
|
|
1610
|
+
const key = !executePending && POLL_INFLIGHT_STATES.includes(status) && intentId
|
|
1585
1611
|
? `${intentId}:${status}${waiting ? ":resend" : ""}`
|
|
1586
1612
|
: null;
|
|
1587
1613
|
if (key === (m.poll?.key ?? null))
|
|
@@ -66,10 +66,11 @@ export interface IntentState {
|
|
|
66
66
|
/**
|
|
67
67
|
* True while the latest status poll was the server's anonymous view of a
|
|
68
68
|
* stealth or shielded intent (xochi#563): its on-chain legs were withheld, so
|
|
69
|
-
* null hashes mean "not shown", not "not there".
|
|
70
|
-
*
|
|
69
|
+
* null hashes mean "not shown", not "not there". False after an owner read.
|
|
70
|
+
* Null until the first status reply, so the host does not render an owner
|
|
71
|
+
* verdict before it has read one. Never a token.
|
|
71
72
|
*/
|
|
72
|
-
legsWithheld: boolean;
|
|
73
|
+
legsWithheld: boolean | null;
|
|
73
74
|
reconciling: boolean;
|
|
74
75
|
/** Plain-language refund explanation; set only on a `refunded` status. */
|
|
75
76
|
refundReason: string | null;
|
package/dist/esm/shared/index.js
CHANGED
|
@@ -388,9 +388,9 @@ export const QuoteResponseSchema = z
|
|
|
388
388
|
// Optional: the POST omits it, and the GET omits it too on a pull-less route.
|
|
389
389
|
nonce: z.number().int().min(0).optional(),
|
|
390
390
|
error: z.string().optional(),
|
|
391
|
-
// Present on
|
|
392
|
-
//
|
|
393
|
-
//
|
|
391
|
+
// Present on declined quotes: `error_category` is a stable machine key for
|
|
392
|
+
// the reject class, `detail` the solver's raw reason. Absent on solvable
|
|
393
|
+
// quotes.
|
|
394
394
|
//
|
|
395
395
|
// Enumerated so the generated spec documents the vocabulary agents branch on,
|
|
396
396
|
// and so adding a category without declaring it fails the contract tests.
|
|
@@ -405,7 +405,7 @@ export const QuoteResponseSchema = z
|
|
|
405
405
|
error_category: z
|
|
406
406
|
.string()
|
|
407
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."),
|
|
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), conversion_unavailable (transient price verification, retry), inventory_unverified (transient, retry), quote_holds_exhausted (transient, retry), idempotency_key_in_flight (transient, retry with the same key), idempotency_unavailable (transient, retry with the same key), 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
409
|
detail: z.string().optional(),
|
|
410
410
|
// Present when `error_category` is `amount_too_small` because the route's
|
|
411
411
|
// FIXED settlement cost consumed too much of the order: the smallest order
|