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