@xochi/client 0.0.0-stage → 0.1.1
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 +54 -0
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/dist/cjs/claim.d.ts +33 -0
- package/dist/cjs/claim.js +44 -0
- package/dist/cjs/fee.d.ts +247 -0
- package/dist/cjs/fee.js +295 -0
- package/dist/cjs/index.d.ts +18 -0
- package/dist/cjs/index.js +33 -0
- package/dist/cjs/machine/claimInfo.d.ts +29 -0
- package/dist/cjs/machine/claimInfo.js +26 -0
- package/dist/cjs/machine/decisions.d.ts +452 -0
- package/dist/cjs/machine/decisions.js +665 -0
- package/dist/cjs/machine/deposit.d.ts +113 -0
- package/dist/cjs/machine/deposit.js +265 -0
- package/dist/cjs/machine/index.d.ts +23 -0
- package/dist/cjs/machine/index.js +53 -0
- package/dist/cjs/machine/machine.d.ts +409 -0
- package/dist/cjs/machine/machine.js +1615 -0
- package/dist/cjs/machine/phase.d.ts +24 -0
- package/dist/cjs/machine/phase.js +45 -0
- package/dist/cjs/machine/quoteChecks.d.ts +41 -0
- package/dist/cjs/machine/quoteChecks.js +82 -0
- package/dist/cjs/machine/receipt.d.ts +151 -0
- package/dist/cjs/machine/receipt.js +69 -0
- package/dist/cjs/machine/recipientEcho.d.ts +16 -0
- package/dist/cjs/machine/recipientEcho.js +27 -0
- package/dist/cjs/machine/solverPin.d.ts +110 -0
- package/dist/cjs/machine/solverPin.js +214 -0
- package/dist/cjs/machine/state.d.ts +167 -0
- package/dist/cjs/machine/state.js +31 -0
- package/dist/cjs/machine/transition.d.ts +15 -0
- package/dist/cjs/machine/transition.js +20 -0
- package/dist/cjs/machine/validateRequest.d.ts +15 -0
- package/dist/cjs/machine/validateRequest.js +127 -0
- package/dist/cjs/machine/view.d.ts +3 -0
- package/dist/cjs/machine/view.js +74 -0
- package/dist/cjs/machine/wire.d.ts +527 -0
- package/dist/cjs/machine/wire.js +280 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/shared/api.d.ts +9 -0
- package/dist/cjs/shared/api.js +5 -0
- package/dist/cjs/shared/chains.d.ts +243 -0
- package/dist/cjs/shared/chains.js +250 -0
- package/dist/cjs/shared/eip712.d.ts +44 -0
- package/dist/cjs/shared/eip712.js +64 -0
- package/dist/cjs/shared/index.d.ts +15 -0
- package/dist/cjs/shared/index.js +31 -0
- package/dist/cjs/shared/intentStream.d.ts +74 -0
- package/dist/cjs/shared/intentStream.js +59 -0
- package/dist/cjs/shared/intents.d.ts +88 -0
- package/dist/cjs/shared/intents.js +227 -0
- package/dist/cjs/shared/schemas.d.ts +928 -0
- package/dist/cjs/shared/schemas.js +524 -0
- package/dist/cjs/shared/stealth.d.ts +53 -0
- package/dist/cjs/shared/stealth.js +68 -0
- package/dist/cjs/shared/trading.d.ts +153 -0
- package/dist/cjs/shared/trading.js +185 -0
- package/dist/cjs/shared/types.d.ts +37 -0
- package/dist/cjs/shared/types.js +7 -0
- package/dist/cjs/shared/validators.d.ts +30 -0
- package/dist/cjs/shared/validators.js +79 -0
- package/dist/cjs/transport/client.d.ts +234 -0
- package/dist/cjs/transport/client.js +1456 -0
- package/dist/esm/claim.d.ts +33 -0
- package/dist/esm/claim.js +41 -0
- package/dist/esm/fee.d.ts +247 -0
- package/dist/esm/fee.js +285 -0
- package/dist/esm/index.d.ts +18 -0
- package/dist/esm/index.js +17 -0
- package/dist/esm/machine/claimInfo.d.ts +29 -0
- package/dist/esm/machine/claimInfo.js +23 -0
- package/dist/esm/machine/decisions.d.ts +452 -0
- package/dist/esm/machine/decisions.js +640 -0
- package/dist/esm/machine/deposit.d.ts +113 -0
- package/dist/esm/machine/deposit.js +260 -0
- package/dist/esm/machine/index.d.ts +23 -0
- package/dist/esm/machine/index.js +23 -0
- package/dist/esm/machine/machine.d.ts +409 -0
- package/dist/esm/machine/machine.js +1609 -0
- package/dist/esm/machine/phase.d.ts +24 -0
- package/dist/esm/machine/phase.js +42 -0
- package/dist/esm/machine/quoteChecks.d.ts +41 -0
- package/dist/esm/machine/quoteChecks.js +78 -0
- package/dist/esm/machine/receipt.d.ts +151 -0
- package/dist/esm/machine/receipt.js +64 -0
- package/dist/esm/machine/recipientEcho.d.ts +16 -0
- package/dist/esm/machine/recipientEcho.js +24 -0
- package/dist/esm/machine/solverPin.d.ts +110 -0
- package/dist/esm/machine/solverPin.js +210 -0
- package/dist/esm/machine/state.d.ts +167 -0
- package/dist/esm/machine/state.js +28 -0
- package/dist/esm/machine/transition.d.ts +15 -0
- package/dist/esm/machine/transition.js +17 -0
- package/dist/esm/machine/validateRequest.d.ts +15 -0
- package/dist/esm/machine/validateRequest.js +123 -0
- package/dist/esm/machine/view.d.ts +3 -0
- package/dist/esm/machine/view.js +71 -0
- package/dist/esm/machine/wire.d.ts +527 -0
- package/dist/esm/machine/wire.js +266 -0
- package/dist/esm/shared/api.d.ts +9 -0
- package/dist/esm/shared/api.js +4 -0
- package/dist/esm/shared/chains.d.ts +243 -0
- package/dist/esm/shared/chains.js +236 -0
- package/dist/esm/shared/eip712.d.ts +44 -0
- package/dist/esm/shared/eip712.js +61 -0
- package/dist/esm/shared/index.d.ts +15 -0
- package/dist/esm/shared/index.js +15 -0
- package/dist/esm/shared/intentStream.d.ts +74 -0
- package/dist/esm/shared/intentStream.js +55 -0
- package/dist/esm/shared/intents.d.ts +88 -0
- package/dist/esm/shared/intents.js +213 -0
- package/dist/esm/shared/schemas.d.ts +928 -0
- package/dist/esm/shared/schemas.js +521 -0
- package/dist/esm/shared/stealth.d.ts +53 -0
- package/dist/esm/shared/stealth.js +64 -0
- package/dist/esm/shared/trading.d.ts +153 -0
- package/dist/esm/shared/trading.js +182 -0
- package/dist/esm/shared/types.d.ts +37 -0
- package/dist/esm/shared/types.js +6 -0
- package/dist/esm/shared/validators.d.ts +30 -0
- package/dist/esm/shared/validators.js +74 -0
- package/dist/esm/transport/client.d.ts +234 -0
- package/dist/esm/transport/client.js +1447 -0
- package/package.json +61 -4
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed pin for origin-pull authorizations: binds both WHERE the funds go
|
|
3
|
+
* and HOW MUCH, before the wallet signs.
|
|
4
|
+
*
|
|
5
|
+
* Two axes can move funds on the origin leg. The recipient (pull `to` for
|
|
6
|
+
* ERC-3009, `spender` for Permit2) decides who collects. The amount (`value` for
|
|
7
|
+
* ERC-3009, `permitted.amount` for Permit2) decides how much. The user's
|
|
8
|
+
* signature is the on-chain authority for both (ERC-3009 moves exactly `value`,
|
|
9
|
+
* Permit2 caps the pull at `permitted.amount`), so a pull whose amount exceeds
|
|
10
|
+
* the intended input can overdraw the wallet even to a correctly-pinned
|
|
11
|
+
* recipient. This pin binds the recipient to an operator allowlist AND the amount
|
|
12
|
+
* to the intended input, refusing to sign on either mismatch. The trust anchors
|
|
13
|
+
* are the client's own config and the user's own quoted amount, neither of which
|
|
14
|
+
* the quote server can influence.
|
|
15
|
+
*
|
|
16
|
+
* Defense in depth: the Xochi worker independently refuses to serve a quote whose
|
|
17
|
+
* pull amount does not equal `from_amount` (packages/worker-lib), and the
|
|
18
|
+
* on-chain token / Permit2 contract verifies the signature over the amount
|
|
19
|
+
* actually pulled. So a compromised quote server cannot overdraw even if this
|
|
20
|
+
* client check is bypassed.
|
|
21
|
+
*
|
|
22
|
+
* Permit2 has no on-chain recipient guard (the spender chooses the recipient at
|
|
23
|
+
* call time), so its recipient is ALWAYS fail-closed: an unpinned permit2 pull is
|
|
24
|
+
* rejected. ERC-3009 binds `to` in the signed digest and the token enforces
|
|
25
|
+
* `msg.sender == to`, so an unpinned `to` stays bounded (accepted unless a hard
|
|
26
|
+
* pin is requested). Once an allowlist is configured, both methods enforce it.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors the raxol agent-side pin (`solver_allowed?/2`). See GitHub #333
|
|
29
|
+
* (recipient pin) and the #170 origin-leg amount binding.
|
|
30
|
+
*/
|
|
31
|
+
import { extractPullAmount } from "../shared/index.js";
|
|
32
|
+
const ERC3009_TYPE = "ReceiveWithAuthorization";
|
|
33
|
+
const PERMIT2_TYPE = "PermitWitnessTransferFrom";
|
|
34
|
+
/**
|
|
35
|
+
* Validate a served origin-pull authorization against the solver pin. Returns
|
|
36
|
+
* `{ ok: true }` when the wallet may sign, or `{ ok: false, field, reason }` to
|
|
37
|
+
* abort before signing.
|
|
38
|
+
*
|
|
39
|
+
* `expectedAmount` is the intended input amount (origin-token smallest units, the
|
|
40
|
+
* user's own quoted `from_amount`). The pull is rejected unless its collected
|
|
41
|
+
* amount equals it, so a served pull cannot overdraw beyond what the user meant
|
|
42
|
+
* to spend even when the recipient is correctly pinned.
|
|
43
|
+
*/
|
|
44
|
+
export function validateSolverPin(pull, config, expectedAmount) {
|
|
45
|
+
// message values are `unknown` off the wire; checkRecipient guards the field.
|
|
46
|
+
const message = pull.message;
|
|
47
|
+
let recipient;
|
|
48
|
+
switch (pull.primaryType) {
|
|
49
|
+
case ERC3009_TYPE:
|
|
50
|
+
recipient = checkRecipient("pull_to", message.to, "erc3009", config.erc3009Allowlist, config);
|
|
51
|
+
break;
|
|
52
|
+
case PERMIT2_TYPE:
|
|
53
|
+
recipient = checkRecipient("pull_spender", message.spender, "permit2", config.permit2Allowlist, config);
|
|
54
|
+
break;
|
|
55
|
+
default:
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
field: "pull_type",
|
|
59
|
+
reason: `unsupported origin-pull authorization type: ${pull.primaryType}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (!recipient.ok)
|
|
63
|
+
return recipient;
|
|
64
|
+
return checkAmount(pull, expectedAmount);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Bind the DESTINATION leg of the intent payload to what the client rendered,
|
|
68
|
+
* before the wallet signs it.
|
|
69
|
+
*
|
|
70
|
+
* `validateSolverPin` above protects the origin leg: how much leaves the wallet
|
|
71
|
+
* and who collects it. This is the mirror for the other end -- how much comes
|
|
72
|
+
* back and who receives it -- and it exists because those two fields are the only
|
|
73
|
+
* ones a user cannot verify by reading a wallet prompt. A payload whose
|
|
74
|
+
* `toAmount` is lower than the number on screen, or whose `recipient` is an
|
|
75
|
+
* address the user never chose, produces a wallet prompt that looks entirely
|
|
76
|
+
* normal.
|
|
77
|
+
*
|
|
78
|
+
* The trust anchor is the same as the origin pin's and that is the whole point:
|
|
79
|
+
* the expectation comes from the client's own rendered quote, never from another
|
|
80
|
+
* field of the same served response. The Xochi worker independently refuses to
|
|
81
|
+
* serve a quote whose `eip712.message.toAmount` disagrees with the `to_amount` it
|
|
82
|
+
* advertises (`servedPayoutMismatch`, packages/worker-lib), so against a healthy
|
|
83
|
+
* backend this check never fires. It covers what that one structurally cannot:
|
|
84
|
+
* anything that reaches the payload after the worker has vouched for it, or a
|
|
85
|
+
* worker that is itself compromised. A firing check here is an incident, not a
|
|
86
|
+
* tuning problem.
|
|
87
|
+
*
|
|
88
|
+
* Fail-closed on a missing or unreadable field: never sign a payout we cannot
|
|
89
|
+
* read.
|
|
90
|
+
*/
|
|
91
|
+
export function validatePayoutPin(intent, expected) {
|
|
92
|
+
const message = intent.message;
|
|
93
|
+
const servedAmount = message.toAmount;
|
|
94
|
+
if (typeof servedAmount !== "string" && typeof servedAmount !== "number") {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
field: "payout_amount",
|
|
98
|
+
reason: "intent payout amount is missing or unparseable",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (!sameAmount(servedAmount, expected.toAmount)) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
field: "payout_amount",
|
|
105
|
+
reason: `intent payout ${String(servedAmount)} does not match the quoted output ${expected.toAmount}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Fail closed rather than skip: not knowing the expected recipient is exactly
|
|
109
|
+
// the case where signing blind is worst.
|
|
110
|
+
if (!expected.recipient) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
field: "payout_recipient",
|
|
114
|
+
reason: "no expected recipient to check the payout against",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const servedRecipient = message.recipient;
|
|
118
|
+
if (typeof servedRecipient !== "string" || servedRecipient === "") {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
field: "payout_recipient",
|
|
122
|
+
reason: "intent payout recipient is missing or unparseable",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (!sameRecipient(servedRecipient, expected.recipient)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
field: "payout_recipient",
|
|
129
|
+
reason: `intent payout recipient ${servedRecipient} is not the address this quote was for`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { ok: true };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Compare two smallest-unit amounts by value, not by spelling.
|
|
136
|
+
*
|
|
137
|
+
* A string compare would reject `"0998000"` or a numeric `998000` against
|
|
138
|
+
* `"998000"` even though both mean the same payout, and this check fails closed:
|
|
139
|
+
* a false negative blocks a legitimate swap at the wallet prompt. BigInt is also
|
|
140
|
+
* required for correctness at scale, since an 18-decimal payout is past
|
|
141
|
+
* `Number.MAX_SAFE_INTEGER`. Falls back to exact string equality only when a
|
|
142
|
+
* value will not parse as an integer, so an unexpected format still has to match
|
|
143
|
+
* verbatim rather than being waved through.
|
|
144
|
+
*/
|
|
145
|
+
function sameAmount(served, expected) {
|
|
146
|
+
try {
|
|
147
|
+
return BigInt(served) === BigInt(expected);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return String(served) === expected;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Compare two recipients per address family. An EVM (`0x`-hex) pair compares
|
|
155
|
+
* case-insensitively; anything else (base58 Tron/Solana) is case-sensitive and
|
|
156
|
+
* compares verbatim, because lowercasing a base58 address changes it. Mirrors
|
|
157
|
+
* the worker-side rule and Riddler's own `token_value/1`.
|
|
158
|
+
*/
|
|
159
|
+
function sameRecipient(a, b) {
|
|
160
|
+
return a.startsWith("0x") && b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Bind the pull's collected amount to the intended input. Fail-closed: a
|
|
164
|
+
* missing/unparseable amount is rejected (never sign an authorization whose value
|
|
165
|
+
* we cannot read), as is any amount that does not match exactly. Riddler builds
|
|
166
|
+
* the pull value straight from `from_amount` with no fee or buffer, so exact
|
|
167
|
+
* equality is correct.
|
|
168
|
+
*/
|
|
169
|
+
function checkAmount(pull, expectedAmount) {
|
|
170
|
+
const amount = extractPullAmount(pull);
|
|
171
|
+
if (amount === null) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
field: "pull_amount",
|
|
175
|
+
reason: "origin-pull amount is missing or unparseable",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (amount !== expectedAmount) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
field: "pull_amount",
|
|
182
|
+
reason: `origin-pull amount ${amount.toString()} does not match the intended input ${expectedAmount.toString()}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { ok: true };
|
|
186
|
+
}
|
|
187
|
+
function checkRecipient(field, recipient, method, allowlist, config) {
|
|
188
|
+
const addr = typeof recipient === "string" ? recipient.toLowerCase() : null;
|
|
189
|
+
if (allowlist.length === 0) {
|
|
190
|
+
// No pin configured for this method. ERC-3009 is bounded by the signed `to`
|
|
191
|
+
// + on-chain msg.sender == to, so accept unless a hard pin is demanded.
|
|
192
|
+
// Permit2 has no such guard, so it must always be pinned.
|
|
193
|
+
if (method === "erc3009" && !config.requirePin)
|
|
194
|
+
return { ok: true };
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
field,
|
|
198
|
+
reason: method === "permit2"
|
|
199
|
+
? "permit2 origin pull requires a pinned spender address"
|
|
200
|
+
: "solver pin required but no allowlist is configured",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (addr !== null && allowlist.includes(addr))
|
|
204
|
+
return { ok: true };
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
field,
|
|
208
|
+
reason: `origin-pull ${field} ${String(recipient)} is not an allowed solver address`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent lifecycle's state, and the error value it carries.
|
|
3
|
+
*/
|
|
4
|
+
import type { ChainId, IntentStatus, SelectableChainId, SettlementType } from "../shared/index.js";
|
|
5
|
+
import type { QuoteResponse } from "./wire.js";
|
|
6
|
+
import type { StoredDepositAttestation } from "./deposit.js";
|
|
7
|
+
/**
|
|
8
|
+
* A failure the UI can categorize by HTTP code and, on 429/503 backpressure,
|
|
9
|
+
* run a retry countdown for. Most failures are a plain string instead.
|
|
10
|
+
*/
|
|
11
|
+
export interface StructuredError {
|
|
12
|
+
message: string;
|
|
13
|
+
code?: number;
|
|
14
|
+
retryAfter?: number;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Why a sign ceremony stopped before the wallet was asked for the signature
|
|
19
|
+
* that moves funds, while the quote is still the one to show. Typed so the
|
|
20
|
+
* renderer words it and nothing parses a message. No funds move on any of
|
|
21
|
+
* these: the first three stop Confirm before any prompt; the pull kinds stop
|
|
22
|
+
* after signature 1, before signature 2 (the origin pull) is requested.
|
|
23
|
+
*/
|
|
24
|
+
export type SignRefusal =
|
|
25
|
+
/** The wallet is not on the origin chain the signature binds. */
|
|
26
|
+
{
|
|
27
|
+
kind: "wrong_chain";
|
|
28
|
+
chainId: SelectableChainId;
|
|
29
|
+
}
|
|
30
|
+
/** The account or network changed since this quote was made. */
|
|
31
|
+
| {
|
|
32
|
+
kind: "wallet_changed";
|
|
33
|
+
}
|
|
34
|
+
/** The payload to sign does not pay what the quote shows, to whom it shows. */
|
|
35
|
+
| {
|
|
36
|
+
kind: "payout_mismatch";
|
|
37
|
+
}
|
|
38
|
+
/** The served origin-pull authorization names an unpinned spender/recipient or amount. */
|
|
39
|
+
| {
|
|
40
|
+
kind: "pull_unpinned";
|
|
41
|
+
}
|
|
42
|
+
/** The token approval the origin pull needs was not granted. */
|
|
43
|
+
| {
|
|
44
|
+
kind: "approval_required";
|
|
45
|
+
};
|
|
46
|
+
export interface IntentState {
|
|
47
|
+
status: IntentStatus;
|
|
48
|
+
intentId: string | null;
|
|
49
|
+
quoteId: string | null;
|
|
50
|
+
quote: QuoteResponse | null;
|
|
51
|
+
error: string | StructuredError | null;
|
|
52
|
+
txHash: string | null;
|
|
53
|
+
receivingTxHash: string | null;
|
|
54
|
+
/** Public fill receipt (Riddler #632) + its chain; drives the receipt CTA and the settled state. */
|
|
55
|
+
settlementTxHash: string | null;
|
|
56
|
+
settlementChainId: ChainId | null;
|
|
57
|
+
/**
|
|
58
|
+
* Riddler's granular fill substatus, verbatim (`filling` | `filled` | ...).
|
|
59
|
+
* Read only to tell a delivered destination leg apart from one still in
|
|
60
|
+
* flight: Riddler can hold an intent at top-level `settling` with
|
|
61
|
+
* `substatus: filled` indefinitely (xochi#376). Never advances the lifecycle.
|
|
62
|
+
* Also `filled` when a status poll reads `settlementConfirmed` (a private
|
|
63
|
+
* intent's fill confirmed, its receipt withheld from an anonymous read).
|
|
64
|
+
*/
|
|
65
|
+
substatus: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* True while the latest status poll was the server's anonymous view of a
|
|
68
|
+
* stealth or shielded intent (xochi#563): its on-chain legs were withheld, so
|
|
69
|
+
* null hashes mean "not shown", not "not there". The host offers wallet SIWE
|
|
70
|
+
* recovery on it; a later owner read (no marker) clears it. Never a token.
|
|
71
|
+
*/
|
|
72
|
+
legsWithheld: boolean;
|
|
73
|
+
reconciling: boolean;
|
|
74
|
+
/** Plain-language refund explanation; set only on a `refunded` status. */
|
|
75
|
+
refundReason: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Wallet account connected when this quote was produced. The quote's EIP-712
|
|
78
|
+
* digest is bound to it, so `sign()` refuses when the live wallet has since
|
|
79
|
+
* switched accounts (wrong-signer), and a mid-flow change resets the intent.
|
|
80
|
+
* Null until a quote lands.
|
|
81
|
+
*/
|
|
82
|
+
quotedAddress: string | null;
|
|
83
|
+
/** Wallet chain id at quote time; guards against a wrong-chain signature. */
|
|
84
|
+
quotedChainId: number | null;
|
|
85
|
+
/**
|
|
86
|
+
* Pay amount (base units) this quote was requested at. The receipt renders
|
|
87
|
+
* the HELD quote against THIS figure, never the live input field: during the
|
|
88
|
+
* debounce-plus-fetch window after an edit, the two describe different
|
|
89
|
+
* trades, and mixing them shows a garbage implied rate and a false
|
|
90
|
+
* below-market flag. Null until a quote lands.
|
|
91
|
+
*/
|
|
92
|
+
quotedFromAmount: string | null;
|
|
93
|
+
/**
|
|
94
|
+
* Settlement preference this quote was requested under. The receipt states
|
|
95
|
+
* the HELD quote's settlement, never the live form's: the two come apart when
|
|
96
|
+
* the user flips settlement while a quote is on screen, and the wallet signs
|
|
97
|
+
* the held one. Null until a quote lands.
|
|
98
|
+
*/
|
|
99
|
+
quotedSettlement: SettlementType | null;
|
|
100
|
+
/**
|
|
101
|
+
* Deposit-acceptance deadline (unix seconds) for a deposit route, or null.
|
|
102
|
+
*
|
|
103
|
+
* Held on the INTENT, not read off `state.quote`, because it is a fact about
|
|
104
|
+
* the solver's acceptance window rather than about a price. The two come apart
|
|
105
|
+
* on a resumed swap: no quote is ever synthesized (a stale price shown as
|
|
106
|
+
* current would be a lie), so a quote-sourced deadline was undefined there and
|
|
107
|
+
* the status poll fell back to its flat 10-minute ceiling while the restored
|
|
108
|
+
* screen counted down the real 30-minute window. The poll then stopped and
|
|
109
|
+
* said so, next to a countdown saying otherwise.
|
|
110
|
+
*
|
|
111
|
+
* `pollCeilingAttempts` reads this, so it must be set anywhere an intent can
|
|
112
|
+
* enter `awaiting_deposit`: quote ingest and resume both.
|
|
113
|
+
*/
|
|
114
|
+
depositDeadline: number | null;
|
|
115
|
+
/**
|
|
116
|
+
* The VERIFIED deposit binding: the exact fields the solver signed plus the
|
|
117
|
+
* signature over them, as proven by `verifyDepositAttestation` at ingest.
|
|
118
|
+
*
|
|
119
|
+
* Held so it can be persisted with the swap record and re-proven on resume. A
|
|
120
|
+
* resumed deposit address is read back out of localStorage, which is not a
|
|
121
|
+
* trust boundary, so it has to be checked by the same code that checks a live
|
|
122
|
+
* one rather than trusted because it was ours once. Null on any route without
|
|
123
|
+
* an attested deposit.
|
|
124
|
+
*/
|
|
125
|
+
attestedDeposit: StoredDepositAttestation | null;
|
|
126
|
+
/**
|
|
127
|
+
* True once the status poll has hit its ceiling and stopped for this intent.
|
|
128
|
+
*
|
|
129
|
+
* Surfaced because a stopped poll and a poll that has found nothing yet are
|
|
130
|
+
* otherwise indistinguishable on screen: both render a spinner. A view that
|
|
131
|
+
* claims to be checking must be able to tell whether it still is.
|
|
132
|
+
*/
|
|
133
|
+
pollExhausted: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Set on a `failed` read after an execute (or a revealed deposit address)
|
|
136
|
+
* that does not carry the Worker's nothing-moved marker
|
|
137
|
+
* (`failureMayHaveMoved`): the order did not complete and the funds may have
|
|
138
|
+
* moved. Terminal and never offered Retry: the phase is `may-have-moved`, the
|
|
139
|
+
* poll is stopped, and no quote starts until Reset.
|
|
140
|
+
*/
|
|
141
|
+
mayHaveMoved: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Set when this client stops resending an execute the Worker answered
|
|
144
|
+
* not-accepted (`decideExecuteResend` gives up), when the execute's own
|
|
145
|
+
* answer says the intent reads `quoted` again (the Worker ended its claim
|
|
146
|
+
* without finishing the intent), or when a resumed `executing` intent (no
|
|
147
|
+
* quote and no sign flow in this tab) reads `quoted`: the order was not
|
|
148
|
+
* sent, but the signed request stays executable until its deadline, so
|
|
149
|
+
* there is no Retry yet. The status stays `executing`, the phase is
|
|
150
|
+
* `not-sent`, and the poll runs on until the Worker ends the intent:
|
|
151
|
+
* `failed` with `NOT_EXECUTED_SUBSTATUS` is Retry, and so is a 404 read past
|
|
152
|
+
* the Worker's finish (`unexecutedFinishAt`: only the quote sweep deletes a
|
|
153
|
+
* row, only one never claimed, and past the finish nothing can claim it). A
|
|
154
|
+
* 404 before the finish, or with no quote to compute it (a resumed intent),
|
|
155
|
+
* holds: it may come from a router, a proxy or a rollback while the signed
|
|
156
|
+
* request is still claimable. Cleared by any other read than `quoted`
|
|
157
|
+
* (another execute claimed it: shown in doubt) and by leaving `executing`.
|
|
158
|
+
*/
|
|
159
|
+
notSent: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Set when a sign ceremony was refused before the funds-moving signature
|
|
162
|
+
* (see SignRefusal). The intent is `quoted` so the quote stays on screen; a
|
|
163
|
+
* new quote, a successful Confirm or a reset clears it.
|
|
164
|
+
*/
|
|
165
|
+
refusal: SignRefusal | null;
|
|
166
|
+
}
|
|
167
|
+
export declare const INITIAL_INTENT_STATE: IntentState;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent lifecycle's state, and the error value it carries.
|
|
3
|
+
*/
|
|
4
|
+
export const INITIAL_INTENT_STATE = {
|
|
5
|
+
status: "idle",
|
|
6
|
+
intentId: null,
|
|
7
|
+
quoteId: null,
|
|
8
|
+
quote: null,
|
|
9
|
+
error: null,
|
|
10
|
+
txHash: null,
|
|
11
|
+
receivingTxHash: null,
|
|
12
|
+
settlementTxHash: null,
|
|
13
|
+
settlementChainId: null,
|
|
14
|
+
substatus: null,
|
|
15
|
+
legsWithheld: false,
|
|
16
|
+
reconciling: false,
|
|
17
|
+
refundReason: null,
|
|
18
|
+
quotedAddress: null,
|
|
19
|
+
quotedChainId: null,
|
|
20
|
+
quotedFromAmount: null,
|
|
21
|
+
quotedSettlement: null,
|
|
22
|
+
depositDeadline: null,
|
|
23
|
+
attestedDeposit: null,
|
|
24
|
+
pollExhausted: false,
|
|
25
|
+
mayHaveMoved: false,
|
|
26
|
+
notSent: false,
|
|
27
|
+
refusal: null,
|
|
28
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-transition reducer for status-driven state.
|
|
3
|
+
*
|
|
4
|
+
* Validates each move against a predicate, no-ops on an invalid one, and
|
|
5
|
+
* otherwise merges { status, ...updates }. Pure: the machine reports the
|
|
6
|
+
* rejected move itself (an `Emit` log effect), so this never touches a console.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Apply a validated status transition to a state object. Returns the previous
|
|
10
|
+
* state unchanged (same reference) when the move is invalid, otherwise the
|
|
11
|
+
* merged next state. Never mutates `prev`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function applyStatusTransition<TStatus extends string, TState extends {
|
|
14
|
+
status: TStatus;
|
|
15
|
+
}>(prev: TState, to: TStatus, canTransition: (from: TStatus, to: TStatus) => boolean, updates?: Partial<TState>): TState;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-transition reducer for status-driven state.
|
|
3
|
+
*
|
|
4
|
+
* Validates each move against a predicate, no-ops on an invalid one, and
|
|
5
|
+
* otherwise merges { status, ...updates }. Pure: the machine reports the
|
|
6
|
+
* rejected move itself (an `Emit` log effect), so this never touches a console.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Apply a validated status transition to a state object. Returns the previous
|
|
10
|
+
* state unchanged (same reference) when the move is invalid, otherwise the
|
|
11
|
+
* merged next state. Never mutates `prev`.
|
|
12
|
+
*/
|
|
13
|
+
export function applyStatusTransition(prev, to, canTransition, updates) {
|
|
14
|
+
if (!canTransition(prev.status, to))
|
|
15
|
+
return prev;
|
|
16
|
+
return { ...prev, status: to, ...updates };
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote request validation.
|
|
3
|
+
*
|
|
4
|
+
* Composable validation functions using functional patterns. Each validator
|
|
5
|
+
* returns a Result type that can be combined. Pure: the deadline check takes
|
|
6
|
+
* the clock as an argument, so the machine validates against the same `now`
|
|
7
|
+
* that stamped the request.
|
|
8
|
+
*/
|
|
9
|
+
import type { QuoteRequest } from "./wire.js";
|
|
10
|
+
export interface SwapValidationResult {
|
|
11
|
+
isValid: boolean;
|
|
12
|
+
errors: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Every reason `params` is not a sendable quote request, judged at `nowMs`. */
|
|
15
|
+
export declare const validateSwapParams: (params: Partial<QuoteRequest>, nowMs: number) => SwapValidationResult;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote request validation.
|
|
3
|
+
*
|
|
4
|
+
* Composable validation functions using functional patterns. Each validator
|
|
5
|
+
* returns a Result type that can be combined. Pure: the deadline check takes
|
|
6
|
+
* the clock as an argument, so the machine validates against the same `now`
|
|
7
|
+
* that stamped the request.
|
|
8
|
+
*/
|
|
9
|
+
import { isChainId, isTronChainId, isSettlementType, ETH_ADDRESS_REGEX, ZERO_ADDRESS, ETH_ADDRESS_LENGTH, MAX_DEADLINE_OFFSET_SEC, MAX_SLIPPAGE_PERCENT, MAX_SLIPPAGE_BPS, isValidTronAddress, } from "../shared/index.js";
|
|
10
|
+
const err = (error) => ({ ok: false, error });
|
|
11
|
+
const okVoid = { ok: true };
|
|
12
|
+
/** Chain validators: short-circuit on first error */
|
|
13
|
+
const chain = (...validators) => (value) => {
|
|
14
|
+
for (const v of validators) {
|
|
15
|
+
const result = v(value);
|
|
16
|
+
if (!result.ok)
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
return okVoid;
|
|
20
|
+
};
|
|
21
|
+
const notEmpty = (s) => (s && s.trim() !== "" ? okVoid : err("Required"));
|
|
22
|
+
const matchesRegex = (regex, message) => (s) => regex.test(s) ? okVoid : err(message);
|
|
23
|
+
const notEqual = (forbidden, message) => (v) => v !== forbidden ? okVoid : err(message);
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Address Validators
|
|
26
|
+
// ============================================================
|
|
27
|
+
const startsWithHex = (s) => s.startsWith("0x") ? okVoid : err("Must start with 0x");
|
|
28
|
+
const hasCorrectLength = (s) => s.length === ETH_ADDRESS_LENGTH ? okVoid : err(`Must be ${ETH_ADDRESS_LENGTH} characters`);
|
|
29
|
+
const hasValidHexChars = matchesRegex(ETH_ADDRESS_REGEX, "Contains invalid characters");
|
|
30
|
+
const notZeroAddress = notEqual(ZERO_ADDRESS, "Zero address is not allowed");
|
|
31
|
+
const validateAddress = chain(notEmpty, startsWithHex, hasCorrectLength, hasValidHexChars, notZeroAddress);
|
|
32
|
+
const validateTokenAddress = chain(validateAddress, notEqual(ZERO_ADDRESS, "Use wrapped token address (WETH)"));
|
|
33
|
+
const errorOf = (result) => (result.ok ? "" : result.error);
|
|
34
|
+
// Individual field validators as simple functions
|
|
35
|
+
const validateWallet = (wallet) => !wallet
|
|
36
|
+
? ["Wallet is required"]
|
|
37
|
+
: !validateAddress(wallet).ok
|
|
38
|
+
? [`Wallet: ${errorOf(validateAddress(wallet))}`]
|
|
39
|
+
: [];
|
|
40
|
+
// Origin may be a non-EVM chain (Tron deposit route) as well as EVM.
|
|
41
|
+
const validateFromChain = (chainId) => chainId === undefined
|
|
42
|
+
? ["Source chain is required"]
|
|
43
|
+
: !isChainId(chainId) && !isTronChainId(chainId)
|
|
44
|
+
? ["Invalid source chain"]
|
|
45
|
+
: [];
|
|
46
|
+
// Destination may be a non-EVM chain (Tron); the origin stays EVM.
|
|
47
|
+
const validateToChain = (chainId) => chainId === undefined
|
|
48
|
+
? ["Destination chain is required"]
|
|
49
|
+
: !isChainId(chainId) && !isTronChainId(chainId)
|
|
50
|
+
? ["Invalid destination chain"]
|
|
51
|
+
: [];
|
|
52
|
+
// The source token can be a Tron TRC-20 (base58) on a Tron-origin deposit route.
|
|
53
|
+
const validateFromToken = (token) => {
|
|
54
|
+
if (!token)
|
|
55
|
+
return ["Source token is required"];
|
|
56
|
+
if (validateTokenAddress(token).ok || isValidTronAddress(token))
|
|
57
|
+
return [];
|
|
58
|
+
return [`Source token: ${errorOf(validateTokenAddress(token))}`];
|
|
59
|
+
};
|
|
60
|
+
// The destination token can be a Tron TRC-20 (base58) on a Tron destination, so
|
|
61
|
+
// accept either an EVM token address or a valid Tron address.
|
|
62
|
+
const validateToToken = (token) => {
|
|
63
|
+
if (!token)
|
|
64
|
+
return ["Destination token is required"];
|
|
65
|
+
if (validateTokenAddress(token).ok || isValidTronAddress(token))
|
|
66
|
+
return [];
|
|
67
|
+
return [`Destination token: ${errorOf(validateTokenAddress(token))}`];
|
|
68
|
+
};
|
|
69
|
+
const validateFromAmount = (amount) => {
|
|
70
|
+
if (!amount)
|
|
71
|
+
return ["Amount is required"];
|
|
72
|
+
try {
|
|
73
|
+
const bigAmount = BigInt(amount);
|
|
74
|
+
return bigAmount > 0n ? [] : ["Amount must be greater than 0"];
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return ["Invalid amount format"];
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const validateDeadline = (deadline, nowMs) => {
|
|
81
|
+
if (deadline === undefined)
|
|
82
|
+
return ["Deadline is required"];
|
|
83
|
+
const now = Math.floor(nowMs / 1000);
|
|
84
|
+
if (deadline <= now)
|
|
85
|
+
return ["Deadline must be in the future"];
|
|
86
|
+
if (deadline > now + MAX_DEADLINE_OFFSET_SEC)
|
|
87
|
+
return ["Deadline cannot exceed 1 hour"];
|
|
88
|
+
return [];
|
|
89
|
+
};
|
|
90
|
+
const validateSlippageBps = (bps) => bps === undefined
|
|
91
|
+
? ["Slippage tolerance is required"]
|
|
92
|
+
: bps < 1
|
|
93
|
+
? ["Slippage must be at least 0.01%"]
|
|
94
|
+
: bps > MAX_SLIPPAGE_BPS
|
|
95
|
+
? [`Slippage cannot exceed ${MAX_SLIPPAGE_PERCENT}%`]
|
|
96
|
+
: [];
|
|
97
|
+
const validateSettlement = (pref) => !pref
|
|
98
|
+
? ["Settlement preference is required"]
|
|
99
|
+
: !isSettlementType(pref)
|
|
100
|
+
? ["Invalid settlement preference"]
|
|
101
|
+
: [];
|
|
102
|
+
const validateSameToken = (p) => p.fromToken &&
|
|
103
|
+
p.toToken &&
|
|
104
|
+
p.fromChainId === p.toChainId &&
|
|
105
|
+
p.fromToken.toLowerCase() === p.toToken.toLowerCase()
|
|
106
|
+
? ["Cannot swap token to itself"]
|
|
107
|
+
: [];
|
|
108
|
+
/** Every reason `params` is not a sendable quote request, judged at `nowMs`. */
|
|
109
|
+
export const validateSwapParams = (params, nowMs) => {
|
|
110
|
+
const errors = [
|
|
111
|
+
...(params.preview ? [] : validateWallet(params.wallet)),
|
|
112
|
+
...validateFromChain(params.fromChainId),
|
|
113
|
+
...validateToChain(params.toChainId),
|
|
114
|
+
...validateFromToken(params.fromToken),
|
|
115
|
+
...validateToToken(params.toToken),
|
|
116
|
+
...validateFromAmount(params.fromAmount),
|
|
117
|
+
...validateDeadline(params.deadline, nowMs),
|
|
118
|
+
...validateSlippageBps(params.slippageBps),
|
|
119
|
+
...validateSettlement(params.settlementPreference),
|
|
120
|
+
...validateSameToken(params),
|
|
121
|
+
];
|
|
122
|
+
return { isValid: errors.length === 0, errors };
|
|
123
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* view(model) -> Receipt. Pure; the renderer draws this and nothing it derives
|
|
3
|
+
* on its own from the raw quote.
|
|
4
|
+
*/
|
|
5
|
+
import { MAX_EXECUTE_RESENDS } from "../shared/index.js";
|
|
6
|
+
import { canSignQuote } from "./decisions.js";
|
|
7
|
+
import { holdsCurrentQuote } from "./machine.js";
|
|
8
|
+
import { phaseFromStatus } from "./phase.js";
|
|
9
|
+
import { costOf, payoutOf, settlementDisclosure, } from "./receipt.js";
|
|
10
|
+
function quoteSlot(model) {
|
|
11
|
+
const { status, quote } = model.intent;
|
|
12
|
+
// The receipt STAYS MOUNTED while a replacement is fetched, but only over a
|
|
13
|
+
// solvable quote: after a decline the held quote's figures are empty.
|
|
14
|
+
if (quote && (status === "quoted" || (status === "quoting" && quote.canSolve))) {
|
|
15
|
+
return {
|
|
16
|
+
kind: "ready",
|
|
17
|
+
executable: !quote.preview,
|
|
18
|
+
refreshing: status === "quoting",
|
|
19
|
+
expiresInSec: model.quoteExpiresIn,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (status === "quoting")
|
|
23
|
+
return { kind: "loading" };
|
|
24
|
+
if (quote && !quote.canSolve)
|
|
25
|
+
return { kind: "declined", reason: quote.error ?? null };
|
|
26
|
+
return { kind: "none" };
|
|
27
|
+
}
|
|
28
|
+
function signingSlot(model) {
|
|
29
|
+
const quote = model.intent.quote;
|
|
30
|
+
const signatures = quote?.pullAuthorization ? 2 : 1;
|
|
31
|
+
const flow = model.sign;
|
|
32
|
+
if (flow) {
|
|
33
|
+
if (flow.stage === "execute") {
|
|
34
|
+
const attempt = flow.execute;
|
|
35
|
+
return typeof attempt?.resendAt === "number"
|
|
36
|
+
? {
|
|
37
|
+
kind: "busy",
|
|
38
|
+
retryAt: Math.ceil(attempt.resendAt / 1000),
|
|
39
|
+
resend: attempt.resends + 1,
|
|
40
|
+
of: MAX_EXECUTE_RESENDS,
|
|
41
|
+
}
|
|
42
|
+
: { kind: "submitted" };
|
|
43
|
+
}
|
|
44
|
+
return flow.stage === "intent"
|
|
45
|
+
? { kind: "in_wallet", step: 1, of: signatures }
|
|
46
|
+
: { kind: "in_wallet", step: 2, of: 2 };
|
|
47
|
+
}
|
|
48
|
+
if (model.awaitingPull)
|
|
49
|
+
return { kind: "awaiting_second" };
|
|
50
|
+
// A quote for a trade the form no longer describes is shown, never offered.
|
|
51
|
+
if (!canSignQuote(model.intent.status, quote) || !holdsCurrentQuote(model)) {
|
|
52
|
+
return { kind: "unavailable" };
|
|
53
|
+
}
|
|
54
|
+
if (model.intent.refusal)
|
|
55
|
+
return { kind: "refused", refusal: model.intent.refusal };
|
|
56
|
+
return quote?.depositAddress ? { kind: "deposit" } : { kind: "ready", signatures };
|
|
57
|
+
}
|
|
58
|
+
export function view(model) {
|
|
59
|
+
const { status, reconciling, mayHaveMoved, notSent, quote, quotedSettlement } = model.intent;
|
|
60
|
+
// A held quote is disclosed under the settlement it was requested (and is
|
|
61
|
+
// signed) under, not whatever the form says now.
|
|
62
|
+
const settlement = (quote && quotedSettlement) || model.ctx.settlementPreference;
|
|
63
|
+
return {
|
|
64
|
+
phase: phaseFromStatus(status, { reconciling, mayHaveMoved, notSent }),
|
|
65
|
+
quote: quoteSlot(model),
|
|
66
|
+
payout: payoutOf(quote),
|
|
67
|
+
cost: costOf(quote),
|
|
68
|
+
settlement: settlementDisclosure(settlement),
|
|
69
|
+
signing: signingSlot(model),
|
|
70
|
+
};
|
|
71
|
+
}
|