@anywalletkit/protocol-swap2-thorchain 0.3.5 → 0.4.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/README.md +84 -0
- package/dist/{chunk-LYJOENYE.js → chunk-EIAXGWNG.js} +521 -106
- package/dist/chunk-EIAXGWNG.js.map +1 -0
- package/dist/{chunk-QLDZS2KO.js → chunk-YKA6IYKX.js} +2 -2
- package/dist/index.cjs +520 -105
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -2
- package/dist/protocol.cjs +448 -79
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.js +2 -2
- package/dist/provider.cjs +520 -105
- package/dist/provider.cjs.map +1 -1
- package/dist/provider.d.cts +59 -2
- package/dist/provider.d.ts +59 -2
- package/dist/provider.js +3 -1
- package/package.json +6 -6
- package/dist/chunk-LYJOENYE.js.map +0 -1
- /package/dist/{chunk-QLDZS2KO.js.map → chunk-YKA6IYKX.js.map} +0 -0
package/dist/provider.cjs
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var provider_exports = {};
|
|
22
22
|
__export(provider_exports, {
|
|
23
23
|
THORCHAIN_ROUTERS: () => THORCHAIN_ROUTERS,
|
|
24
|
+
THORNODE_CONFIRM_PATHS: () => THORNODE_CONFIRM_PATHS,
|
|
24
25
|
thorchainPins: () => thorchainPins,
|
|
25
26
|
thorchainProvider: () => thorchainProvider
|
|
26
27
|
});
|
|
@@ -68,8 +69,212 @@ function describeThorchainActivity(tx, ctx) {
|
|
|
68
69
|
// src/provider.ts
|
|
69
70
|
var import_viem = require("viem");
|
|
70
71
|
var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
|
|
72
|
+
var import_errors2 = require("@anywalletkit/protocol-swap2-core/errors");
|
|
71
73
|
var import_swap2_assets = require("@anywalletkit/swap2-assets");
|
|
72
74
|
var import_core = require("@anywalletkit/core");
|
|
75
|
+
|
|
76
|
+
// src/confirm.ts
|
|
77
|
+
var import_errors = require("@anywalletkit/protocol-swap2-core/errors");
|
|
78
|
+
var THORNODE_CONFIRM_PATHS = Object.freeze({
|
|
79
|
+
lastblock: "/thorchain/lastblock",
|
|
80
|
+
inboundAddresses: "/thorchain/inbound_addresses",
|
|
81
|
+
asgardVaults: "/thorchain/vaults/asgard",
|
|
82
|
+
mimir: "/thorchain/mimir"
|
|
83
|
+
});
|
|
84
|
+
var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
|
|
85
|
+
var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
|
|
86
|
+
var MIN_CONFIRM_TIMEOUT_MS = 100;
|
|
87
|
+
var MAX_CONFIRM_TIMEOUT_MS = 8e3;
|
|
88
|
+
function confirmOptions(o) {
|
|
89
|
+
const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
|
|
90
|
+
if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
|
|
91
|
+
throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
|
|
92
|
+
}
|
|
93
|
+
const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
|
|
94
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
|
|
95
|
+
throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
|
|
96
|
+
}
|
|
97
|
+
return { maxLagBlocks, timeoutMs };
|
|
98
|
+
}
|
|
99
|
+
var HostUnavailable = class extends Error {
|
|
100
|
+
/**
|
|
101
|
+
* `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
|
|
102
|
+
* `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
|
|
103
|
+
*/
|
|
104
|
+
constructor(host, reason, info) {
|
|
105
|
+
super(`thorchain: ${host} unavailable (${reason})`);
|
|
106
|
+
(0, import_errors.withReason)(this, info ?? "upstream_unavailable");
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
function statusRefusal(status, retryAfter) {
|
|
110
|
+
return (0, import_errors.httpRefusal)(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
|
|
111
|
+
}
|
|
112
|
+
function paused(entry) {
|
|
113
|
+
return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
|
|
114
|
+
}
|
|
115
|
+
function sameTarget(a, b) {
|
|
116
|
+
if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
|
|
117
|
+
return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
118
|
+
}
|
|
119
|
+
function nodeDeadline(ms, outer) {
|
|
120
|
+
const ctrl = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
|
|
122
|
+
const onAbort = () => ctrl.abort();
|
|
123
|
+
if (outer) {
|
|
124
|
+
if (outer.aborted) ctrl.abort();
|
|
125
|
+
else outer.addEventListener("abort", onAbort);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
signal: ctrl.signal,
|
|
129
|
+
clear() {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
outer?.removeEventListener("abort", onAbort);
|
|
132
|
+
if (!ctrl.signal.aborted) ctrl.abort();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function sameRequestUrl(actual, expected) {
|
|
137
|
+
if (actual === void 0 || actual === null || actual === "") return true;
|
|
138
|
+
if (typeof actual !== "string") return false;
|
|
139
|
+
try {
|
|
140
|
+
const a = new URL(actual);
|
|
141
|
+
const e = new URL(expected);
|
|
142
|
+
return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function nodeRead(host, path, clientId, signal, outer) {
|
|
148
|
+
const url = `${host}${path}`;
|
|
149
|
+
let res;
|
|
150
|
+
try {
|
|
151
|
+
res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (outer?.aborted) throw error;
|
|
154
|
+
throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
|
|
155
|
+
}
|
|
156
|
+
if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
|
|
157
|
+
if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
|
|
158
|
+
try {
|
|
159
|
+
return await res.json();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (outer?.aborted) throw error;
|
|
162
|
+
throw new HostUnavailable(host, `${path} is not JSON`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function int64(v) {
|
|
166
|
+
if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
|
|
167
|
+
if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
171
|
+
var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
|
|
172
|
+
var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
|
|
173
|
+
function inboundAddresses(body) {
|
|
174
|
+
if (!Array.isArray(body)) return null;
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const e of body) {
|
|
177
|
+
if (!isObject(e) || typeof e.chain !== "string") return null;
|
|
178
|
+
const entry = { chain: e.chain };
|
|
179
|
+
for (const k of INBOUND_STRINGS) {
|
|
180
|
+
const v = e[k];
|
|
181
|
+
if (v === void 0) continue;
|
|
182
|
+
if (typeof v !== "string") return null;
|
|
183
|
+
entry[k] = v;
|
|
184
|
+
}
|
|
185
|
+
for (const k of INBOUND_FLAGS) {
|
|
186
|
+
const v = e[k];
|
|
187
|
+
if (v === void 0) continue;
|
|
188
|
+
if (typeof v !== "boolean") return null;
|
|
189
|
+
entry[k] = v;
|
|
190
|
+
}
|
|
191
|
+
out.push(entry);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
function lastblockHeight(body) {
|
|
196
|
+
if (!Array.isArray(body) || body.length === 0) return null;
|
|
197
|
+
let height = -1;
|
|
198
|
+
for (const entry of body) {
|
|
199
|
+
if (!isObject(entry)) return null;
|
|
200
|
+
const h = int64(entry.thorchain);
|
|
201
|
+
if (h === null || h < 0) return null;
|
|
202
|
+
if (h > height) height = h;
|
|
203
|
+
}
|
|
204
|
+
return height;
|
|
205
|
+
}
|
|
206
|
+
function asgardVaults(body) {
|
|
207
|
+
if (!Array.isArray(body)) return null;
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const v of body) {
|
|
210
|
+
if (!isObject(v)) return null;
|
|
211
|
+
if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
|
|
212
|
+
if (v.type !== void 0 && v.type !== "AsgardVault") return null;
|
|
213
|
+
if (!Array.isArray(v.addresses)) return null;
|
|
214
|
+
const addresses = [];
|
|
215
|
+
for (const a of v.addresses) {
|
|
216
|
+
if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
|
|
217
|
+
addresses.push({ chain: a.chain, address: a.address });
|
|
218
|
+
}
|
|
219
|
+
out.push({ pubKey: v.pub_key, status: v.status, addresses });
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
function mimirFlags(body) {
|
|
224
|
+
if (!isObject(body) || Object.keys(body).length === 0) return null;
|
|
225
|
+
const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
|
|
226
|
+
if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
|
|
227
|
+
return (pubKey) => {
|
|
228
|
+
const pub = pubKey.toUpperCase();
|
|
229
|
+
const compromisedKey = `COMPROMISEDVAULT-${pub}`;
|
|
230
|
+
const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
|
|
231
|
+
let flag = null;
|
|
232
|
+
for (const [key, value] of keys) {
|
|
233
|
+
const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
|
|
234
|
+
if (!kind) continue;
|
|
235
|
+
const n = int64(value);
|
|
236
|
+
if (n === null) return "malformed";
|
|
237
|
+
if (n > 0 && flag !== "compromised") flag = kind;
|
|
238
|
+
}
|
|
239
|
+
return flag;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async function nodeHead(host, read) {
|
|
243
|
+
const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
|
|
244
|
+
const height = lastblockHeight(lastblock);
|
|
245
|
+
if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
|
|
246
|
+
return { height, inbound };
|
|
247
|
+
}
|
|
248
|
+
async function vaultVerdict(host, read, inbound, claim) {
|
|
249
|
+
if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
|
|
250
|
+
const entries = inboundAddresses(inbound);
|
|
251
|
+
if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
|
|
252
|
+
const entry = entries.find((e) => e.chain === claim.chain);
|
|
253
|
+
if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
|
|
254
|
+
if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
|
|
255
|
+
return { ok: false, why: "halted", chain: claim.destChain };
|
|
256
|
+
}
|
|
257
|
+
if (!sameTarget(entry?.address, claim.vault)) {
|
|
258
|
+
const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
|
|
259
|
+
const vaults = asgardVaults(asgardBody);
|
|
260
|
+
if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
|
|
261
|
+
const flags = mimirFlags(mimirBody);
|
|
262
|
+
if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
|
|
263
|
+
const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
|
|
264
|
+
const active = holders.find((v) => v.status === "ActiveVault");
|
|
265
|
+
if (!active) {
|
|
266
|
+
if (!holders.length) return { ok: false, why: "another-vault" };
|
|
267
|
+
return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
|
|
268
|
+
}
|
|
269
|
+
const flag = flags(active.pubKey);
|
|
270
|
+
if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
|
|
271
|
+
if (flag) return { ok: false, why: "flagged", flag };
|
|
272
|
+
}
|
|
273
|
+
if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/provider.ts
|
|
73
278
|
var HOSTS = [
|
|
74
279
|
"https://gateway.liquify.com/chain/thorchain_api",
|
|
75
280
|
"https://thorchain.ibs.team/api"
|
|
@@ -85,6 +290,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
|
|
|
85
290
|
var TOLERANCE_BPS = "300";
|
|
86
291
|
var MAX_TOLERANCE_BPS = 500n;
|
|
87
292
|
var QUOTE_VALIDITY_MS = 5 * 6e4;
|
|
293
|
+
var NODE_READ_TIMEOUT_MS = 8e3;
|
|
294
|
+
var CONFIRM_BUDGET_MS = 8e3;
|
|
295
|
+
var LAGGING = /* @__PURE__ */ Symbol("lagging");
|
|
88
296
|
var THOR_CHAIN = {
|
|
89
297
|
"eip155:1": "ETH",
|
|
90
298
|
"eip155:8453": "BASE",
|
|
@@ -165,25 +373,20 @@ function destAsset(destChain, destToken) {
|
|
|
165
373
|
const asset = thorAsset(destChain, a.symbol);
|
|
166
374
|
return asset ? { asset, decimals: a.decimals } : void 0;
|
|
167
375
|
}
|
|
168
|
-
var HostUnavailable = class extends Error {
|
|
169
|
-
constructor(host, reason) {
|
|
170
|
-
super(`thorchain: ${host} unavailable (${reason})`);
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
376
|
async function hostGet(host, path, clientId, signal) {
|
|
174
377
|
let res;
|
|
175
378
|
try {
|
|
176
379
|
res = await fetch(`${host}${path}`, {
|
|
177
380
|
headers: { accept: "application/json", "x-client-id": clientId },
|
|
178
381
|
cache: "no-store",
|
|
179
|
-
signal: signal ?? (0, import_util2.timeoutSignal)(
|
|
382
|
+
signal: signal ?? (0, import_util2.timeoutSignal)(NODE_READ_TIMEOUT_MS)
|
|
180
383
|
});
|
|
181
384
|
} catch (error) {
|
|
182
385
|
if (signal?.aborted) throw error;
|
|
183
386
|
throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
|
|
184
387
|
}
|
|
185
388
|
if (res.status >= 500 || res.status === 429 || res.status === 403) {
|
|
186
|
-
throw new HostUnavailable(host, `HTTP ${res.status}
|
|
389
|
+
throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
|
|
187
390
|
}
|
|
188
391
|
const body = await res.json().catch(() => null);
|
|
189
392
|
return { ok: res.ok, status: res.status, body };
|
|
@@ -208,35 +411,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
|
|
|
208
411
|
if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
|
|
209
412
|
return null;
|
|
210
413
|
}
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
if (!
|
|
214
|
-
|
|
414
|
+
function nodesFailed(failures) {
|
|
415
|
+
const known = failures.filter((f) => f !== void 0);
|
|
416
|
+
if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
|
|
417
|
+
const combined = (0, import_errors2.combinedRefusal)(known.map((f) => ({ message: "", ...f })));
|
|
418
|
+
return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
|
|
215
419
|
}
|
|
216
|
-
function
|
|
217
|
-
|
|
420
|
+
function quoteRejection(text, status, bodyless) {
|
|
421
|
+
if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
|
|
422
|
+
if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
|
|
423
|
+
if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
|
|
424
|
+
if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
|
|
425
|
+
if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
|
|
426
|
+
if (/pool [^ ]* ?(?:does not exist|doesn't exist|not exist|not found|not available|is not available)|no pool/i.test(text)) return { reason: "unsupported_pair" };
|
|
427
|
+
if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
|
|
428
|
+
if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
|
|
429
|
+
const byStatus = status >= 400 ? (0, import_errors2.httpRefusal)(status) : void 0;
|
|
430
|
+
if (byStatus) return byStatus;
|
|
431
|
+
return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
|
|
218
432
|
}
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
|
|
433
|
+
async function inboundAddressesOn(host, clientId) {
|
|
434
|
+
const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
|
|
435
|
+
if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
|
|
436
|
+
const entries = inboundAddresses(res.body);
|
|
437
|
+
if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
|
|
438
|
+
return entries;
|
|
222
439
|
}
|
|
223
|
-
function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
|
|
224
|
-
|
|
440
|
+
function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
|
|
441
|
+
const refused = (reason) => {
|
|
442
|
+
why?.(reason);
|
|
443
|
+
return null;
|
|
444
|
+
};
|
|
445
|
+
if (memo.includes("|")) return refused("recipient_unverifiable");
|
|
225
446
|
const parts = memo.split(":");
|
|
226
|
-
if (parts.length !== 4 && parts.length !== 6) return
|
|
447
|
+
if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
|
|
227
448
|
const [op, asset, destination, limit, affiliate, bps] = parts;
|
|
228
|
-
if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return
|
|
449
|
+
if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
|
|
229
450
|
const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
|
|
230
|
-
if (!sameTarget(destination, expectedTarget)) return
|
|
451
|
+
if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
|
|
231
452
|
if (parts.length === 6) {
|
|
232
|
-
if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return
|
|
453
|
+
if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
|
|
233
454
|
} else if (expectedAffiliate || expectedAffiliateBps) {
|
|
234
|
-
return
|
|
455
|
+
return refused("policy_refused");
|
|
235
456
|
}
|
|
236
457
|
const stream = (limit ?? "").split("/");
|
|
237
|
-
if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return
|
|
458
|
+
if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
|
|
238
459
|
const floor = BigInt(stream[0] ?? "0");
|
|
239
|
-
if (floor <= 0n) return
|
|
460
|
+
if (floor <= 0n) return refused("price_check_failed");
|
|
240
461
|
const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
|
|
241
462
|
return { limit: floor, canonical };
|
|
242
463
|
}
|
|
@@ -248,62 +469,107 @@ function thorchainPins(opts = {}) {
|
|
|
248
469
|
const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
|
|
249
470
|
for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
|
|
250
471
|
const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
|
|
472
|
+
const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
|
|
473
|
+
const askNode = async (host, claim, failures) => {
|
|
474
|
+
const node = nodeDeadline(timeoutMs);
|
|
475
|
+
const read = (path) => nodeRead(host, path, clientId, node.signal);
|
|
476
|
+
try {
|
|
477
|
+
const head = await nodeHead(host, read);
|
|
478
|
+
try {
|
|
479
|
+
return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof HostUnavailable) {
|
|
482
|
+
failures.push((0, import_errors2.refusalOf)(error));
|
|
483
|
+
return { height: head.height, verdict: null };
|
|
484
|
+
}
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (error instanceof HostUnavailable) {
|
|
489
|
+
failures.push((0, import_errors2.refusalOf)(error));
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
} finally {
|
|
494
|
+
node.clear();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
251
497
|
const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
|
|
498
|
+
const { note, reasonOf } = (0, import_errors2.refusalNotes)();
|
|
252
499
|
return Object.freeze({
|
|
253
500
|
callTargets: routersFor,
|
|
254
501
|
spenders: routersFor,
|
|
502
|
+
reasonOf,
|
|
255
503
|
// A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
|
|
256
504
|
depositAddressRoutes: true,
|
|
257
505
|
async verifyBuild(quote, tx, params) {
|
|
258
|
-
if (!params) return "the request this quote answered is unknown";
|
|
506
|
+
if (!params) return note("the request this quote answered is unknown", "policy_refused");
|
|
259
507
|
const src = (0, import_swap2_assets.ccAsset)(params.sourceChain, params.sourceToken);
|
|
260
508
|
const dst = destAsset(params.destChain, params.destToken);
|
|
261
|
-
if (!src || !dst) return "unknown source or destination asset";
|
|
509
|
+
if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
|
|
262
510
|
const chainId = CHAIN_ID[params.sourceChain];
|
|
263
511
|
let vault, memo, router;
|
|
264
512
|
if (chainId) {
|
|
265
|
-
if (!tx.data) return "no router calldata";
|
|
513
|
+
if (!tx.data) return note("no router calldata", "policy_refused");
|
|
266
514
|
let args;
|
|
267
515
|
try {
|
|
268
516
|
const d = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
|
|
269
|
-
if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry
|
|
517
|
+
if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
|
|
270
518
|
args = d.args;
|
|
271
519
|
} catch {
|
|
272
|
-
return "the router calldata does not decode";
|
|
520
|
+
return note("the router calldata does not decode", "policy_refused");
|
|
273
521
|
}
|
|
274
|
-
if (!args || args.length !== 5) return "the router calldata does not decode";
|
|
522
|
+
if (!args || args.length !== 5) return note("the router calldata does not decode", "policy_refused");
|
|
275
523
|
router = tx.to;
|
|
276
|
-
if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return `router ${tx.to} is not pinned
|
|
524
|
+
if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return note(`router ${tx.to} is not pinned`, "vault_mismatch");
|
|
277
525
|
vault = String(args[0]);
|
|
278
526
|
const asset = String(args[1]);
|
|
279
|
-
if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset
|
|
280
|
-
if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return `the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input
|
|
527
|
+
if (!sameTarget(asset, src.contract ?? NATIVE)) return note(`the router call moves ${asset}, not the source asset`, "policy_refused");
|
|
528
|
+
if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return note(`the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`, "policy_refused");
|
|
281
529
|
memo = String(args[3]);
|
|
282
530
|
} else {
|
|
283
|
-
if (!tx.memo || tx.value !== quote.sourceAmount) return "the deposit is not a send of the input with a memo";
|
|
531
|
+
if (!tx.memo || tx.value !== quote.sourceAmount) return note("the deposit is not a send of the input with a memo", "policy_refused");
|
|
284
532
|
vault = tx.to;
|
|
285
533
|
memo = tx.memo;
|
|
286
534
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
535
|
+
let memoRefusal = "recipient_mismatch";
|
|
536
|
+
const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "", (why) => {
|
|
537
|
+
memoRefusal = why;
|
|
538
|
+
});
|
|
539
|
+
if (!checked) return note(`the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`, memoRefusal);
|
|
540
|
+
if (memo !== checked.canonical) return note(`the memo is not the canonical ${checked.canonical}`, "policy_refused");
|
|
541
|
+
if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return note(`the memo's limit ${checked.limit} is below the quoted minimum`, "price_check_failed");
|
|
542
|
+
const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
|
|
543
|
+
const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
|
|
544
|
+
const failures = [];
|
|
545
|
+
const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
|
|
546
|
+
const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
|
|
291
547
|
let agreeing = 0;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
548
|
+
let lagging = false;
|
|
549
|
+
for (const view of views) {
|
|
550
|
+
if (!view?.verdict) continue;
|
|
551
|
+
if (top - view.height > maxLagBlocks) {
|
|
552
|
+
lagging = true;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const v = view.verdict;
|
|
556
|
+
if (!v.ok) {
|
|
557
|
+
switch (v.why) {
|
|
558
|
+
case "halted":
|
|
559
|
+
return note(`${v.chain} is halted / paused`, "chain_halted");
|
|
560
|
+
case "another-vault":
|
|
561
|
+
return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
|
|
562
|
+
case "not-active":
|
|
563
|
+
return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
|
|
564
|
+
case "flagged":
|
|
565
|
+
return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
|
|
566
|
+
case "another-router":
|
|
567
|
+
return note("a THORNode reports another router", "vault_mismatch");
|
|
568
|
+
}
|
|
299
569
|
}
|
|
300
|
-
const entry = entries.find((e) => e.chain === THOR_CHAIN[params.sourceChain]);
|
|
301
|
-
if (paused(entry)) return `${THOR_CHAIN[params.sourceChain]} is halted / paused`;
|
|
302
|
-
if (!sameTarget(entry?.address, vault)) return `a THORNode reports another inbound vault than ${vault}`;
|
|
303
|
-
if (router !== void 0 && !sameTarget(entry?.router, router)) return "a THORNode reports another router";
|
|
304
570
|
if (++agreeing >= 2) return null;
|
|
305
571
|
}
|
|
306
|
-
return "two THORNodes could not confirm the inbound vault";
|
|
572
|
+
return lagging ? note(`two THORNodes within ${maxLagBlocks} blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)`, "stale_node") : note("two THORNodes could not confirm the inbound vault", nodesFailed(failures));
|
|
307
573
|
}
|
|
308
574
|
});
|
|
309
575
|
}
|
|
@@ -343,23 +609,109 @@ function thorchainProvider(config = {}) {
|
|
|
343
609
|
routers.set(chain, set);
|
|
344
610
|
}
|
|
345
611
|
const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
|
|
346
|
-
const
|
|
612
|
+
const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
|
|
613
|
+
const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
|
|
614
|
+
let inbounds;
|
|
615
|
+
let height;
|
|
616
|
+
let heightError;
|
|
617
|
+
let done = false;
|
|
618
|
+
const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
|
|
619
|
+
const settle = () => {
|
|
620
|
+
if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
|
|
621
|
+
done = true;
|
|
622
|
+
resolve(heightError ? { inbounds, heightError } : { inbounds, height });
|
|
623
|
+
};
|
|
624
|
+
const fail = (error) => {
|
|
625
|
+
if (done) return;
|
|
626
|
+
done = true;
|
|
627
|
+
tip.clear();
|
|
628
|
+
reject(error);
|
|
629
|
+
};
|
|
630
|
+
inboundAddressesOn(host, clientId).then((v) => {
|
|
631
|
+
inbounds = v;
|
|
632
|
+
settle();
|
|
633
|
+
}, fail);
|
|
634
|
+
nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
|
|
635
|
+
(body) => {
|
|
636
|
+
tip.clear();
|
|
637
|
+
const h = lastblockHeight(body);
|
|
638
|
+
if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
|
|
639
|
+
else height = h;
|
|
640
|
+
settle();
|
|
641
|
+
},
|
|
642
|
+
(error) => {
|
|
643
|
+
tip.clear();
|
|
644
|
+
if (!(error instanceof HostUnavailable)) return fail(error);
|
|
645
|
+
heightError = error;
|
|
646
|
+
settle();
|
|
647
|
+
}
|
|
648
|
+
);
|
|
649
|
+
});
|
|
650
|
+
const confirmText = (v) => {
|
|
651
|
+
switch (v.why) {
|
|
652
|
+
case "halted":
|
|
653
|
+
return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
|
|
654
|
+
case "another-vault":
|
|
655
|
+
return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
|
|
656
|
+
case "not-active":
|
|
657
|
+
return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
|
|
658
|
+
case "flagged":
|
|
659
|
+
return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
|
|
660
|
+
case "another-router":
|
|
661
|
+
return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
|
|
665
|
+
const read = (path) => nodeRead(host, path, clientId, signal, outer);
|
|
666
|
+
const head = await nodeHead(host, read);
|
|
667
|
+
if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
|
|
668
|
+
const verdict = await vaultVerdict(host, read, head.inbound, claim);
|
|
669
|
+
return verdict.ok ? null : confirmText(verdict);
|
|
670
|
+
};
|
|
671
|
+
const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
|
|
672
|
+
const claim = {
|
|
673
|
+
chain: THOR_CHAIN[chain] ?? chain,
|
|
674
|
+
...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
|
|
675
|
+
vault,
|
|
676
|
+
...router !== void 0 ? { router } : {}
|
|
677
|
+
};
|
|
678
|
+
let lagging = false;
|
|
679
|
+
let asked = 0;
|
|
680
|
+
const failures = [];
|
|
347
681
|
for (const host of confirmHosts) {
|
|
348
682
|
if (host === quoteHost) continue;
|
|
349
|
-
|
|
683
|
+
const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
|
|
684
|
+
if (budget < MIN_CONFIRM_TIMEOUT_MS) {
|
|
685
|
+
failures.push({ reason: "upstream_unavailable" });
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
asked++;
|
|
689
|
+
const node = nodeDeadline(budget, outer);
|
|
350
690
|
try {
|
|
351
|
-
|
|
691
|
+
const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
|
|
692
|
+
if (verdict === LAGGING) {
|
|
693
|
+
lagging = true;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
return verdict;
|
|
352
697
|
} catch (error) {
|
|
353
|
-
if (
|
|
698
|
+
if (outer?.aborted) throw error;
|
|
699
|
+
if (error instanceof HostUnavailable) {
|
|
700
|
+
failures.push((0, import_errors2.refusalOf)(error));
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
354
703
|
throw error;
|
|
704
|
+
} finally {
|
|
705
|
+
node.clear();
|
|
355
706
|
}
|
|
356
|
-
const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
|
|
357
|
-
if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
|
|
358
|
-
if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
|
|
359
|
-
if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
|
|
360
|
-
return null;
|
|
361
707
|
}
|
|
362
|
-
|
|
708
|
+
if (lagging) {
|
|
709
|
+
return { text: `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`, info: { reason: "stale_node" } };
|
|
710
|
+
}
|
|
711
|
+
if (asked === 0) {
|
|
712
|
+
return { text: `the ${CONFIRM_BUDGET_MS / 1e3} s confirmation budget ran out before a second node could be asked to confirm the inbound vault \u2014 retry`, info: { reason: "upstream_unavailable" } };
|
|
713
|
+
}
|
|
714
|
+
return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
|
|
363
715
|
};
|
|
364
716
|
const configuredAffiliate = config.affiliate ?? "";
|
|
365
717
|
const configuredAffiliateBps = config.affiliateBps ?? "";
|
|
@@ -405,24 +757,27 @@ function thorchainProvider(config = {}) {
|
|
|
405
757
|
const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
|
|
406
758
|
const dst = destAsset(params.destChain, params.destToken);
|
|
407
759
|
if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
|
|
408
|
-
params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}
|
|
760
|
+
params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
|
|
409
761
|
return null;
|
|
410
762
|
}
|
|
411
763
|
const amountIn = BigInt(params.amountIn);
|
|
412
764
|
const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
|
|
413
765
|
if (amount1e8 <= 0n) return null;
|
|
414
766
|
if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
|
|
415
|
-
params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals
|
|
767
|
+
params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
|
|
416
768
|
return null;
|
|
417
769
|
}
|
|
418
770
|
const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
|
|
419
771
|
if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
|
|
420
|
-
params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps
|
|
772
|
+
params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
|
|
421
773
|
return null;
|
|
422
774
|
}
|
|
423
775
|
const explicitSlippage = params.slippageBps !== void 0;
|
|
424
776
|
const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
|
|
425
777
|
const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
|
|
778
|
+
let quoteStatus = 0;
|
|
779
|
+
let quoteBodyless = false;
|
|
780
|
+
let quoteBodyAborted = false;
|
|
426
781
|
const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
|
|
427
782
|
const qs = new URLSearchParams({
|
|
428
783
|
from_asset: fromAsset,
|
|
@@ -443,6 +798,9 @@ function thorchainProvider(config = {}) {
|
|
|
443
798
|
}
|
|
444
799
|
if (params.refundAddress) qs.set("refund_address", params.refundAddress);
|
|
445
800
|
const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
|
|
801
|
+
quoteStatus = res.status;
|
|
802
|
+
quoteBodyless = res.body === null && !res.ok;
|
|
803
|
+
quoteBodyAborted = res.body === null && signal?.aborted === true;
|
|
446
804
|
if (Array.isArray(res.body)) return null;
|
|
447
805
|
return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
|
|
448
806
|
};
|
|
@@ -450,13 +808,17 @@ function thorchainProvider(config = {}) {
|
|
|
450
808
|
let checkedMemo = null;
|
|
451
809
|
let inbounds = null;
|
|
452
810
|
let quoteHost = "";
|
|
811
|
+
let quoteHeight = 0;
|
|
812
|
+
let confirmDeadlineAt = 0;
|
|
813
|
+
let heightUnavailable = false;
|
|
814
|
+
const hostFailures = [];
|
|
453
815
|
for (const host of hosts) {
|
|
454
816
|
try {
|
|
455
817
|
if (limitMode === "liquidity_tolerance_bps") {
|
|
456
818
|
q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
|
|
457
819
|
const feeBps = Number(q?.fees?.total_bps);
|
|
458
820
|
if (Number.isFinite(feeBps) && feeBps > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
|
|
459
|
-
params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)
|
|
821
|
+
params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)`, { reason: "policy_refused" });
|
|
460
822
|
return null;
|
|
461
823
|
}
|
|
462
824
|
} else {
|
|
@@ -473,32 +835,44 @@ function thorchainProvider(config = {}) {
|
|
|
473
835
|
}
|
|
474
836
|
}
|
|
475
837
|
if (q?.error || q?.message) {
|
|
476
|
-
params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}
|
|
838
|
+
params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`, quoteBodyAborted ? (0, import_errors2.httpRefusal)(quoteStatus) : quoteRejection(String(q.error ?? q.message), quoteStatus, quoteBodyless));
|
|
477
839
|
return null;
|
|
478
840
|
}
|
|
479
841
|
if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
|
|
480
|
-
params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)
|
|
842
|
+
params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`, quoteBodyAborted ? void 0 : { reason: "upstream_unavailable", ...quoteStatus >= 300 ? { httpStatus: quoteStatus } : {} });
|
|
481
843
|
return null;
|
|
482
844
|
}
|
|
483
845
|
if (chainId ? !q.router : !!q.router) {
|
|
484
|
-
params.onRefusal?.(chainId ? "thorchain: quote has no router for an EVM source" : "thorchain: quote names a router for a deposit source \u2014 execution model changed");
|
|
846
|
+
params.onRefusal?.(chainId ? "thorchain: quote has no router for an EVM source" : "thorchain: quote names a router for a deposit source \u2014 execution model changed", { reason: chainId ? "upstream_unavailable" : "policy_refused" });
|
|
485
847
|
return null;
|
|
486
848
|
}
|
|
487
|
-
|
|
849
|
+
let memoRefusal = "recipient_mismatch";
|
|
850
|
+
checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
|
|
851
|
+
memoRefusal = why;
|
|
852
|
+
});
|
|
488
853
|
if (!checkedMemo) {
|
|
489
|
-
params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})
|
|
854
|
+
params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
|
|
490
855
|
return null;
|
|
491
856
|
}
|
|
492
857
|
const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
|
|
493
858
|
if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
|
|
494
|
-
params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)
|
|
859
|
+
params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
|
|
495
860
|
return null;
|
|
496
861
|
}
|
|
497
|
-
|
|
862
|
+
const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
|
|
863
|
+
const snapshot = await nodeSnapshot(host, signal);
|
|
864
|
+
if ("heightError" in snapshot) {
|
|
865
|
+
heightUnavailable = true;
|
|
866
|
+
throw snapshot.heightError;
|
|
867
|
+
}
|
|
868
|
+
inbounds = snapshot.inbounds;
|
|
869
|
+
quoteHeight = snapshot.height;
|
|
498
870
|
quoteHost = host;
|
|
871
|
+
confirmDeadlineAt = deadlineAt;
|
|
499
872
|
break;
|
|
500
873
|
} catch (error) {
|
|
501
874
|
if (error instanceof HostUnavailable) {
|
|
875
|
+
hostFailures.push((0, import_errors2.refusalOf)(error));
|
|
502
876
|
q = null;
|
|
503
877
|
checkedMemo = null;
|
|
504
878
|
inbounds = null;
|
|
@@ -508,60 +882,72 @@ function thorchainProvider(config = {}) {
|
|
|
508
882
|
}
|
|
509
883
|
}
|
|
510
884
|
if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
|
|
511
|
-
params.onRefusal?.(
|
|
885
|
+
params.onRefusal?.(
|
|
886
|
+
inbounds ? "thorchain: no quote from any node" : heightUnavailable ? "thorchain: THORChain height (/thorchain/lastblock) unavailable from every node that answered the inbound addresses" : "thorchain: inbound addresses unavailable from every node",
|
|
887
|
+
inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
|
|
888
|
+
);
|
|
512
889
|
return null;
|
|
513
890
|
}
|
|
514
891
|
const limit1e8 = checkedMemo.limit;
|
|
515
892
|
const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
|
|
516
893
|
const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
|
|
517
894
|
if (paused(srcInbound) || paused(dstInbound)) {
|
|
518
|
-
params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain
|
|
895
|
+
params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
|
|
519
896
|
return null;
|
|
520
897
|
}
|
|
521
898
|
if (!sameTarget(srcInbound?.address, q.inbound_address)) {
|
|
522
|
-
params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
|
|
899
|
+
params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
|
|
523
900
|
return null;
|
|
524
901
|
}
|
|
525
902
|
if (chainId && !sameTarget(srcInbound?.router, q.router)) {
|
|
526
|
-
params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
|
|
903
|
+
params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
|
|
527
904
|
return null;
|
|
528
905
|
}
|
|
529
906
|
if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
|
|
530
|
-
params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)
|
|
907
|
+
params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
|
|
531
908
|
return null;
|
|
532
909
|
}
|
|
533
|
-
const unconfirmed = await confirmedElsewhere(
|
|
910
|
+
const unconfirmed = await confirmedElsewhere(
|
|
911
|
+
quoteHost,
|
|
912
|
+
quoteHeight,
|
|
913
|
+
params.sourceChain,
|
|
914
|
+
params.destChain,
|
|
915
|
+
q.inbound_address,
|
|
916
|
+
chainId ? q.router : void 0,
|
|
917
|
+
confirmDeadlineAt,
|
|
918
|
+
signal
|
|
919
|
+
);
|
|
534
920
|
if (unconfirmed) {
|
|
535
|
-
params.onRefusal?.(`thorchain: ${unconfirmed}
|
|
921
|
+
params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
|
|
536
922
|
return null;
|
|
537
923
|
}
|
|
538
924
|
const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
|
|
539
925
|
if (tooSmall) {
|
|
540
|
-
params.onRefusal?.(`thorchain: ${tooSmall}
|
|
926
|
+
params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
|
|
541
927
|
return null;
|
|
542
928
|
}
|
|
543
929
|
const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
|
|
544
930
|
if (outUnits <= 0n) {
|
|
545
|
-
params.onRefusal?.("thorchain: expected output rounds to zero");
|
|
931
|
+
params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
|
|
546
932
|
return null;
|
|
547
933
|
}
|
|
548
934
|
const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
|
|
549
935
|
const minUnits = memoMinUnits;
|
|
550
936
|
if (minUnits <= 0n || minUnits > outUnits) {
|
|
551
|
-
params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}
|
|
937
|
+
params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
|
|
552
938
|
return null;
|
|
553
939
|
}
|
|
554
940
|
if (params.slippageBps !== void 0) {
|
|
555
941
|
const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
|
|
556
942
|
if (minUnits < slippageFloor) {
|
|
557
|
-
params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)
|
|
943
|
+
params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
|
|
558
944
|
return null;
|
|
559
945
|
}
|
|
560
946
|
}
|
|
561
947
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
562
948
|
const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
|
|
563
949
|
if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
|
|
564
|
-
params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute
|
|
950
|
+
params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
|
|
565
951
|
return null;
|
|
566
952
|
}
|
|
567
953
|
const assetAddr = src.contract ? src.contract : NATIVE;
|
|
@@ -617,7 +1003,7 @@ function thorchainProvider(config = {}) {
|
|
|
617
1003
|
if (!quote.txRequest) {
|
|
618
1004
|
const sourceChain = quote.providerData?.sourceChain ?? "";
|
|
619
1005
|
if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
|
|
620
|
-
throw
|
|
1006
|
+
throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
|
|
621
1007
|
}
|
|
622
1008
|
return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
|
|
623
1009
|
}
|
|
@@ -634,67 +1020,96 @@ function thorchainProvider(config = {}) {
|
|
|
634
1020
|
async preflightDeposit(quote, tx) {
|
|
635
1021
|
const data = quote.providerData;
|
|
636
1022
|
if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
|
|
637
|
-
throw
|
|
1023
|
+
throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
|
|
638
1024
|
}
|
|
639
1025
|
const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
|
|
640
|
-
if (evmSource && !data.router) throw
|
|
641
|
-
if (!evmSource && !isDepositSource(data.sourceChain)) throw
|
|
1026
|
+
if (evmSource && !data.router) throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
|
|
1027
|
+
if (!evmSource && !isDepositSource(data.sourceChain)) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: source chain has no executor");
|
|
642
1028
|
if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
|
|
643
|
-
throw
|
|
1029
|
+
throw (0, import_errors2.refusal)("quote_expired", "thorchain: quote expired before broadcast");
|
|
644
1030
|
}
|
|
645
|
-
if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw
|
|
1031
|
+
if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: the quote's router is not one this module trusts");
|
|
646
1032
|
let inbounds = null;
|
|
647
1033
|
let snapshotHost = "";
|
|
1034
|
+
let snapshotHeight = 0;
|
|
1035
|
+
let confirmDeadlineAt = 0;
|
|
1036
|
+
let heightUnavailable = false;
|
|
1037
|
+
const hostFailures = [];
|
|
648
1038
|
for (const host of hosts) {
|
|
1039
|
+
const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
|
|
649
1040
|
try {
|
|
650
|
-
|
|
1041
|
+
const snapshot = await nodeSnapshot(host);
|
|
1042
|
+
if ("heightError" in snapshot) {
|
|
1043
|
+
heightUnavailable = true;
|
|
1044
|
+
hostFailures.push((0, import_errors2.refusalOf)(snapshot.heightError));
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
inbounds = snapshot.inbounds;
|
|
1048
|
+
snapshotHeight = snapshot.height;
|
|
651
1049
|
snapshotHost = host;
|
|
1050
|
+
confirmDeadlineAt = deadlineAt;
|
|
652
1051
|
break;
|
|
653
1052
|
} catch (error) {
|
|
1053
|
+
inbounds = null;
|
|
654
1054
|
if (!(error instanceof HostUnavailable)) throw error;
|
|
1055
|
+
hostFailures.push((0, import_errors2.refusalOf)(error));
|
|
655
1056
|
}
|
|
656
1057
|
}
|
|
657
|
-
if (!inbounds)
|
|
1058
|
+
if (!inbounds) {
|
|
1059
|
+
throw (0, import_errors2.withReason)(new Error(
|
|
1060
|
+
heightUnavailable ? "thorchain: cannot read THORChain height (/thorchain/lastblock) before broadcast from any node that answered the inbound addresses" : "thorchain: cannot refresh inbound addresses before broadcast"
|
|
1061
|
+
), nodesFailed(hostFailures));
|
|
1062
|
+
}
|
|
658
1063
|
const confirm = async () => {
|
|
659
|
-
const unconfirmed = await confirmedElsewhere(
|
|
660
|
-
|
|
1064
|
+
const unconfirmed = await confirmedElsewhere(
|
|
1065
|
+
snapshotHost,
|
|
1066
|
+
snapshotHeight,
|
|
1067
|
+
data.sourceChain,
|
|
1068
|
+
data.destChain,
|
|
1069
|
+
data.inboundAddress,
|
|
1070
|
+
evmSource ? data.router : void 0,
|
|
1071
|
+
confirmDeadlineAt
|
|
1072
|
+
);
|
|
1073
|
+
if (unconfirmed) throw (0, import_errors2.withReason)(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
|
|
661
1074
|
};
|
|
662
1075
|
const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
|
|
663
1076
|
const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
|
|
664
|
-
if (paused(src) || paused(dst)) throw
|
|
665
|
-
if (!sameTarget(src?.address, data.inboundAddress)) throw
|
|
1077
|
+
if (paused(src) || paused(dst)) throw (0, import_errors2.refusal)("chain_halted", "thorchain: source or destination chain trading is paused");
|
|
1078
|
+
if (!sameTarget(src?.address, data.inboundAddress)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: inbound vault rotated after quote");
|
|
666
1079
|
if (!evmSource) {
|
|
667
1080
|
if (tx.data !== void 0 || tx.approvalAddress !== void 0 || tx.bodyBoc !== void 0 || tx.typedData !== void 0 || tx.serializedTx !== void 0 || tx.stateInitBoc !== void 0) {
|
|
668
|
-
throw
|
|
1081
|
+
throw (0, import_errors2.refusal)("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
|
|
669
1082
|
}
|
|
670
1083
|
if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
|
|
671
|
-
throw
|
|
1084
|
+
throw (0, import_errors2.refusal)(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
|
|
672
1085
|
}
|
|
673
1086
|
const srcAsset = (0, import_swap2_assets.ccAssetByToken)(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
|
|
674
|
-
if (!srcAsset) throw
|
|
1087
|
+
if (!srcAsset) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: unknown source asset");
|
|
675
1088
|
const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
|
|
676
1089
|
const tooSmall = belowThorMinimums(amount1e8, src, null);
|
|
677
|
-
if (tooSmall) throw
|
|
1090
|
+
if (tooSmall) throw (0, import_errors2.refusal)("amount_too_small", `thorchain: ${tooSmall}`);
|
|
678
1091
|
await confirm();
|
|
679
1092
|
return;
|
|
680
1093
|
}
|
|
681
1094
|
if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
|
|
682
|
-
throw
|
|
1095
|
+
throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: router rotated after quote");
|
|
683
1096
|
}
|
|
684
|
-
if (!tx.data) throw
|
|
1097
|
+
if (!tx.data) throw (0, import_errors2.refusal)("policy_refused", "thorchain: missing depositWithExpiry calldata");
|
|
685
1098
|
let decoded;
|
|
686
1099
|
try {
|
|
687
1100
|
decoded = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
|
|
688
1101
|
} catch {
|
|
689
|
-
throw
|
|
1102
|
+
throw (0, import_errors2.refusal)("policy_refused", "thorchain: malformed depositWithExpiry calldata");
|
|
690
1103
|
}
|
|
691
1104
|
const args = decoded.args;
|
|
692
1105
|
if (decoded.functionName !== "depositWithExpiry" || !args || args.length !== 5 || !sameTarget(String(args[0]), data.inboundAddress) || !sameTarget(String(args[1]), data.assetAddress) || BigInt(args[2]) !== BigInt(quote.sourceAmount) || String(args[3]) !== data.memo || BigInt(args[4]) !== BigInt(data.expiry) || tx.chainId !== CHAIN_ID[data.sourceChain]) {
|
|
693
|
-
|
|
1106
|
+
const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
|
|
1107
|
+
throw (0, import_errors2.refusal)(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
|
|
694
1108
|
}
|
|
695
1109
|
const tokenSource = !sameTarget(data.assetAddress, NATIVE);
|
|
696
1110
|
if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
|
|
697
|
-
|
|
1111
|
+
const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
|
|
1112
|
+
throw (0, import_errors2.refusal)(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
|
|
698
1113
|
}
|
|
699
1114
|
await confirm();
|
|
700
1115
|
},
|