@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
|
@@ -8,8 +8,212 @@ import {
|
|
|
8
8
|
// src/provider.ts
|
|
9
9
|
import { decodeFunctionData, encodeFunctionData } from "viem";
|
|
10
10
|
import { timeoutSignal } from "@anywalletkit/protocol-swap2-core/util";
|
|
11
|
+
import { combinedRefusal, httpRefusal as httpRefusal2, refusal, refusalNotes, refusalOf, withReason as withReason2 } from "@anywalletkit/protocol-swap2-core/errors";
|
|
11
12
|
import { ccAsset, ccAssetByToken } from "@anywalletkit/swap2-assets";
|
|
12
13
|
import { bigintToNumber } from "@anywalletkit/core";
|
|
14
|
+
|
|
15
|
+
// src/confirm.ts
|
|
16
|
+
import { httpRefusal, withReason } from "@anywalletkit/protocol-swap2-core/errors";
|
|
17
|
+
var THORNODE_CONFIRM_PATHS = Object.freeze({
|
|
18
|
+
lastblock: "/thorchain/lastblock",
|
|
19
|
+
inboundAddresses: "/thorchain/inbound_addresses",
|
|
20
|
+
asgardVaults: "/thorchain/vaults/asgard",
|
|
21
|
+
mimir: "/thorchain/mimir"
|
|
22
|
+
});
|
|
23
|
+
var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
|
|
24
|
+
var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
|
|
25
|
+
var MIN_CONFIRM_TIMEOUT_MS = 100;
|
|
26
|
+
var MAX_CONFIRM_TIMEOUT_MS = 8e3;
|
|
27
|
+
function confirmOptions(o) {
|
|
28
|
+
const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
|
|
29
|
+
if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
|
|
30
|
+
throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
|
|
31
|
+
}
|
|
32
|
+
const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
|
|
33
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
|
|
34
|
+
throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
|
|
35
|
+
}
|
|
36
|
+
return { maxLagBlocks, timeoutMs };
|
|
37
|
+
}
|
|
38
|
+
var HostUnavailable = class extends Error {
|
|
39
|
+
/**
|
|
40
|
+
* `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
|
|
41
|
+
* `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
|
|
42
|
+
*/
|
|
43
|
+
constructor(host, reason, info) {
|
|
44
|
+
super(`thorchain: ${host} unavailable (${reason})`);
|
|
45
|
+
withReason(this, info ?? "upstream_unavailable");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function statusRefusal(status, retryAfter) {
|
|
49
|
+
return httpRefusal(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
|
|
50
|
+
}
|
|
51
|
+
function paused(entry) {
|
|
52
|
+
return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
|
|
53
|
+
}
|
|
54
|
+
function sameTarget(a, b) {
|
|
55
|
+
if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
|
|
56
|
+
return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
57
|
+
}
|
|
58
|
+
function nodeDeadline(ms, outer) {
|
|
59
|
+
const ctrl = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
|
|
61
|
+
const onAbort = () => ctrl.abort();
|
|
62
|
+
if (outer) {
|
|
63
|
+
if (outer.aborted) ctrl.abort();
|
|
64
|
+
else outer.addEventListener("abort", onAbort);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
signal: ctrl.signal,
|
|
68
|
+
clear() {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
outer?.removeEventListener("abort", onAbort);
|
|
71
|
+
if (!ctrl.signal.aborted) ctrl.abort();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function sameRequestUrl(actual, expected) {
|
|
76
|
+
if (actual === void 0 || actual === null || actual === "") return true;
|
|
77
|
+
if (typeof actual !== "string") return false;
|
|
78
|
+
try {
|
|
79
|
+
const a = new URL(actual);
|
|
80
|
+
const e = new URL(expected);
|
|
81
|
+
return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function nodeRead(host, path, clientId, signal, outer) {
|
|
87
|
+
const url = `${host}${path}`;
|
|
88
|
+
let res;
|
|
89
|
+
try {
|
|
90
|
+
res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (outer?.aborted) throw error;
|
|
93
|
+
throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
|
|
94
|
+
}
|
|
95
|
+
if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
|
|
96
|
+
if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
|
|
97
|
+
try {
|
|
98
|
+
return await res.json();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (outer?.aborted) throw error;
|
|
101
|
+
throw new HostUnavailable(host, `${path} is not JSON`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function int64(v) {
|
|
105
|
+
if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
|
|
106
|
+
if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
110
|
+
var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
|
|
111
|
+
var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
|
|
112
|
+
function inboundAddresses(body) {
|
|
113
|
+
if (!Array.isArray(body)) return null;
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const e of body) {
|
|
116
|
+
if (!isObject(e) || typeof e.chain !== "string") return null;
|
|
117
|
+
const entry = { chain: e.chain };
|
|
118
|
+
for (const k of INBOUND_STRINGS) {
|
|
119
|
+
const v = e[k];
|
|
120
|
+
if (v === void 0) continue;
|
|
121
|
+
if (typeof v !== "string") return null;
|
|
122
|
+
entry[k] = v;
|
|
123
|
+
}
|
|
124
|
+
for (const k of INBOUND_FLAGS) {
|
|
125
|
+
const v = e[k];
|
|
126
|
+
if (v === void 0) continue;
|
|
127
|
+
if (typeof v !== "boolean") return null;
|
|
128
|
+
entry[k] = v;
|
|
129
|
+
}
|
|
130
|
+
out.push(entry);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function lastblockHeight(body) {
|
|
135
|
+
if (!Array.isArray(body) || body.length === 0) return null;
|
|
136
|
+
let height = -1;
|
|
137
|
+
for (const entry of body) {
|
|
138
|
+
if (!isObject(entry)) return null;
|
|
139
|
+
const h = int64(entry.thorchain);
|
|
140
|
+
if (h === null || h < 0) return null;
|
|
141
|
+
if (h > height) height = h;
|
|
142
|
+
}
|
|
143
|
+
return height;
|
|
144
|
+
}
|
|
145
|
+
function asgardVaults(body) {
|
|
146
|
+
if (!Array.isArray(body)) return null;
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const v of body) {
|
|
149
|
+
if (!isObject(v)) return null;
|
|
150
|
+
if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
|
|
151
|
+
if (v.type !== void 0 && v.type !== "AsgardVault") return null;
|
|
152
|
+
if (!Array.isArray(v.addresses)) return null;
|
|
153
|
+
const addresses = [];
|
|
154
|
+
for (const a of v.addresses) {
|
|
155
|
+
if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
|
|
156
|
+
addresses.push({ chain: a.chain, address: a.address });
|
|
157
|
+
}
|
|
158
|
+
out.push({ pubKey: v.pub_key, status: v.status, addresses });
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function mimirFlags(body) {
|
|
163
|
+
if (!isObject(body) || Object.keys(body).length === 0) return null;
|
|
164
|
+
const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
|
|
165
|
+
if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
|
|
166
|
+
return (pubKey) => {
|
|
167
|
+
const pub = pubKey.toUpperCase();
|
|
168
|
+
const compromisedKey = `COMPROMISEDVAULT-${pub}`;
|
|
169
|
+
const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
|
|
170
|
+
let flag = null;
|
|
171
|
+
for (const [key, value] of keys) {
|
|
172
|
+
const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
|
|
173
|
+
if (!kind) continue;
|
|
174
|
+
const n = int64(value);
|
|
175
|
+
if (n === null) return "malformed";
|
|
176
|
+
if (n > 0 && flag !== "compromised") flag = kind;
|
|
177
|
+
}
|
|
178
|
+
return flag;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async function nodeHead(host, read) {
|
|
182
|
+
const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
|
|
183
|
+
const height = lastblockHeight(lastblock);
|
|
184
|
+
if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
|
|
185
|
+
return { height, inbound };
|
|
186
|
+
}
|
|
187
|
+
async function vaultVerdict(host, read, inbound, claim) {
|
|
188
|
+
if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
|
|
189
|
+
const entries = inboundAddresses(inbound);
|
|
190
|
+
if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
|
|
191
|
+
const entry = entries.find((e) => e.chain === claim.chain);
|
|
192
|
+
if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
|
|
193
|
+
if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
|
|
194
|
+
return { ok: false, why: "halted", chain: claim.destChain };
|
|
195
|
+
}
|
|
196
|
+
if (!sameTarget(entry?.address, claim.vault)) {
|
|
197
|
+
const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
|
|
198
|
+
const vaults = asgardVaults(asgardBody);
|
|
199
|
+
if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
|
|
200
|
+
const flags = mimirFlags(mimirBody);
|
|
201
|
+
if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
|
|
202
|
+
const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
|
|
203
|
+
const active = holders.find((v) => v.status === "ActiveVault");
|
|
204
|
+
if (!active) {
|
|
205
|
+
if (!holders.length) return { ok: false, why: "another-vault" };
|
|
206
|
+
return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
|
|
207
|
+
}
|
|
208
|
+
const flag = flags(active.pubKey);
|
|
209
|
+
if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
|
|
210
|
+
if (flag) return { ok: false, why: "flagged", flag };
|
|
211
|
+
}
|
|
212
|
+
if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
|
|
213
|
+
return { ok: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/provider.ts
|
|
13
217
|
var HOSTS = [
|
|
14
218
|
"https://gateway.liquify.com/chain/thorchain_api",
|
|
15
219
|
"https://thorchain.ibs.team/api"
|
|
@@ -25,6 +229,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
|
|
|
25
229
|
var TOLERANCE_BPS = "300";
|
|
26
230
|
var MAX_TOLERANCE_BPS = 500n;
|
|
27
231
|
var QUOTE_VALIDITY_MS = 5 * 6e4;
|
|
232
|
+
var NODE_READ_TIMEOUT_MS = 8e3;
|
|
233
|
+
var CONFIRM_BUDGET_MS = 8e3;
|
|
234
|
+
var LAGGING = /* @__PURE__ */ Symbol("lagging");
|
|
28
235
|
var THOR_CHAIN = {
|
|
29
236
|
"eip155:1": "ETH",
|
|
30
237
|
"eip155:8453": "BASE",
|
|
@@ -105,25 +312,20 @@ function destAsset(destChain, destToken) {
|
|
|
105
312
|
const asset = thorAsset(destChain, a.symbol);
|
|
106
313
|
return asset ? { asset, decimals: a.decimals } : void 0;
|
|
107
314
|
}
|
|
108
|
-
var HostUnavailable = class extends Error {
|
|
109
|
-
constructor(host, reason) {
|
|
110
|
-
super(`thorchain: ${host} unavailable (${reason})`);
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
315
|
async function hostGet(host, path, clientId, signal) {
|
|
114
316
|
let res;
|
|
115
317
|
try {
|
|
116
318
|
res = await fetch(`${host}${path}`, {
|
|
117
319
|
headers: { accept: "application/json", "x-client-id": clientId },
|
|
118
320
|
cache: "no-store",
|
|
119
|
-
signal: signal ?? timeoutSignal(
|
|
321
|
+
signal: signal ?? timeoutSignal(NODE_READ_TIMEOUT_MS)
|
|
120
322
|
});
|
|
121
323
|
} catch (error) {
|
|
122
324
|
if (signal?.aborted) throw error;
|
|
123
325
|
throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
|
|
124
326
|
}
|
|
125
327
|
if (res.status >= 500 || res.status === 429 || res.status === 403) {
|
|
126
|
-
throw new HostUnavailable(host, `HTTP ${res.status}
|
|
328
|
+
throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
|
|
127
329
|
}
|
|
128
330
|
const body = await res.json().catch(() => null);
|
|
129
331
|
return { ok: res.ok, status: res.status, body };
|
|
@@ -148,35 +350,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
|
|
|
148
350
|
if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
|
|
149
351
|
return null;
|
|
150
352
|
}
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
if (!
|
|
154
|
-
|
|
353
|
+
function nodesFailed(failures) {
|
|
354
|
+
const known = failures.filter((f) => f !== void 0);
|
|
355
|
+
if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
|
|
356
|
+
const combined = combinedRefusal(known.map((f) => ({ message: "", ...f })));
|
|
357
|
+
return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
|
|
155
358
|
}
|
|
156
|
-
function
|
|
157
|
-
|
|
359
|
+
function quoteRejection(text, status, bodyless) {
|
|
360
|
+
if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
|
|
361
|
+
if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
|
|
362
|
+
if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
|
|
363
|
+
if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
|
|
364
|
+
if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
|
|
365
|
+
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" };
|
|
366
|
+
if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
|
|
367
|
+
if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
|
|
368
|
+
const byStatus = status >= 400 ? httpRefusal2(status) : void 0;
|
|
369
|
+
if (byStatus) return byStatus;
|
|
370
|
+
return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
|
|
158
371
|
}
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
372
|
+
async function inboundAddressesOn(host, clientId) {
|
|
373
|
+
const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
|
|
374
|
+
if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
|
|
375
|
+
const entries = inboundAddresses(res.body);
|
|
376
|
+
if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
|
|
377
|
+
return entries;
|
|
162
378
|
}
|
|
163
|
-
function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
|
|
164
|
-
|
|
379
|
+
function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
|
|
380
|
+
const refused = (reason) => {
|
|
381
|
+
why?.(reason);
|
|
382
|
+
return null;
|
|
383
|
+
};
|
|
384
|
+
if (memo.includes("|")) return refused("recipient_unverifiable");
|
|
165
385
|
const parts = memo.split(":");
|
|
166
|
-
if (parts.length !== 4 && parts.length !== 6) return
|
|
386
|
+
if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
|
|
167
387
|
const [op, asset, destination, limit, affiliate, bps] = parts;
|
|
168
|
-
if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return
|
|
388
|
+
if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
|
|
169
389
|
const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
|
|
170
|
-
if (!sameTarget(destination, expectedTarget)) return
|
|
390
|
+
if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
|
|
171
391
|
if (parts.length === 6) {
|
|
172
|
-
if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return
|
|
392
|
+
if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
|
|
173
393
|
} else if (expectedAffiliate || expectedAffiliateBps) {
|
|
174
|
-
return
|
|
394
|
+
return refused("policy_refused");
|
|
175
395
|
}
|
|
176
396
|
const stream = (limit ?? "").split("/");
|
|
177
|
-
if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return
|
|
397
|
+
if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
|
|
178
398
|
const floor = BigInt(stream[0] ?? "0");
|
|
179
|
-
if (floor <= 0n) return
|
|
399
|
+
if (floor <= 0n) return refused("price_check_failed");
|
|
180
400
|
const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
|
|
181
401
|
return { limit: floor, canonical };
|
|
182
402
|
}
|
|
@@ -188,62 +408,107 @@ function thorchainPins(opts = {}) {
|
|
|
188
408
|
const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
|
|
189
409
|
for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
|
|
190
410
|
const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
|
|
411
|
+
const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
|
|
412
|
+
const askNode = async (host, claim, failures) => {
|
|
413
|
+
const node = nodeDeadline(timeoutMs);
|
|
414
|
+
const read = (path) => nodeRead(host, path, clientId, node.signal);
|
|
415
|
+
try {
|
|
416
|
+
const head = await nodeHead(host, read);
|
|
417
|
+
try {
|
|
418
|
+
return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error instanceof HostUnavailable) {
|
|
421
|
+
failures.push(refusalOf(error));
|
|
422
|
+
return { height: head.height, verdict: null };
|
|
423
|
+
}
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof HostUnavailable) {
|
|
428
|
+
failures.push(refusalOf(error));
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
} finally {
|
|
433
|
+
node.clear();
|
|
434
|
+
}
|
|
435
|
+
};
|
|
191
436
|
const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
|
|
437
|
+
const { note, reasonOf } = refusalNotes();
|
|
192
438
|
return Object.freeze({
|
|
193
439
|
callTargets: routersFor,
|
|
194
440
|
spenders: routersFor,
|
|
441
|
+
reasonOf,
|
|
195
442
|
// A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
|
|
196
443
|
depositAddressRoutes: true,
|
|
197
444
|
async verifyBuild(quote, tx, params) {
|
|
198
|
-
if (!params) return "the request this quote answered is unknown";
|
|
445
|
+
if (!params) return note("the request this quote answered is unknown", "policy_refused");
|
|
199
446
|
const src = ccAsset(params.sourceChain, params.sourceToken);
|
|
200
447
|
const dst = destAsset(params.destChain, params.destToken);
|
|
201
|
-
if (!src || !dst) return "unknown source or destination asset";
|
|
448
|
+
if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
|
|
202
449
|
const chainId = CHAIN_ID[params.sourceChain];
|
|
203
450
|
let vault, memo, router;
|
|
204
451
|
if (chainId) {
|
|
205
|
-
if (!tx.data) return "no router calldata";
|
|
452
|
+
if (!tx.data) return note("no router calldata", "policy_refused");
|
|
206
453
|
let args;
|
|
207
454
|
try {
|
|
208
455
|
const d = decodeFunctionData({ abi: ROUTER_ABI, data: tx.data });
|
|
209
|
-
if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry
|
|
456
|
+
if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
|
|
210
457
|
args = d.args;
|
|
211
458
|
} catch {
|
|
212
|
-
return "the router calldata does not decode";
|
|
459
|
+
return note("the router calldata does not decode", "policy_refused");
|
|
213
460
|
}
|
|
214
|
-
if (!args || args.length !== 5) return "the router calldata does not decode";
|
|
461
|
+
if (!args || args.length !== 5) return note("the router calldata does not decode", "policy_refused");
|
|
215
462
|
router = tx.to;
|
|
216
|
-
if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return `router ${tx.to} is not pinned
|
|
463
|
+
if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return note(`router ${tx.to} is not pinned`, "vault_mismatch");
|
|
217
464
|
vault = String(args[0]);
|
|
218
465
|
const asset = String(args[1]);
|
|
219
|
-
if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset
|
|
220
|
-
if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return `the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input
|
|
466
|
+
if (!sameTarget(asset, src.contract ?? NATIVE)) return note(`the router call moves ${asset}, not the source asset`, "policy_refused");
|
|
467
|
+
if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return note(`the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`, "policy_refused");
|
|
221
468
|
memo = String(args[3]);
|
|
222
469
|
} else {
|
|
223
|
-
if (!tx.memo || tx.value !== quote.sourceAmount) return "the deposit is not a send of the input with a memo";
|
|
470
|
+
if (!tx.memo || tx.value !== quote.sourceAmount) return note("the deposit is not a send of the input with a memo", "policy_refused");
|
|
224
471
|
vault = tx.to;
|
|
225
472
|
memo = tx.memo;
|
|
226
473
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
474
|
+
let memoRefusal = "recipient_mismatch";
|
|
475
|
+
const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "", (why) => {
|
|
476
|
+
memoRefusal = why;
|
|
477
|
+
});
|
|
478
|
+
if (!checked) return note(`the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`, memoRefusal);
|
|
479
|
+
if (memo !== checked.canonical) return note(`the memo is not the canonical ${checked.canonical}`, "policy_refused");
|
|
480
|
+
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");
|
|
481
|
+
const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
|
|
482
|
+
const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
|
|
483
|
+
const failures = [];
|
|
484
|
+
const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
|
|
485
|
+
const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
|
|
231
486
|
let agreeing = 0;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
487
|
+
let lagging = false;
|
|
488
|
+
for (const view of views) {
|
|
489
|
+
if (!view?.verdict) continue;
|
|
490
|
+
if (top - view.height > maxLagBlocks) {
|
|
491
|
+
lagging = true;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const v = view.verdict;
|
|
495
|
+
if (!v.ok) {
|
|
496
|
+
switch (v.why) {
|
|
497
|
+
case "halted":
|
|
498
|
+
return note(`${v.chain} is halted / paused`, "chain_halted");
|
|
499
|
+
case "another-vault":
|
|
500
|
+
return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
|
|
501
|
+
case "not-active":
|
|
502
|
+
return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
|
|
503
|
+
case "flagged":
|
|
504
|
+
return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
|
|
505
|
+
case "another-router":
|
|
506
|
+
return note("a THORNode reports another router", "vault_mismatch");
|
|
507
|
+
}
|
|
239
508
|
}
|
|
240
|
-
const entry = entries.find((e) => e.chain === THOR_CHAIN[params.sourceChain]);
|
|
241
|
-
if (paused(entry)) return `${THOR_CHAIN[params.sourceChain]} is halted / paused`;
|
|
242
|
-
if (!sameTarget(entry?.address, vault)) return `a THORNode reports another inbound vault than ${vault}`;
|
|
243
|
-
if (router !== void 0 && !sameTarget(entry?.router, router)) return "a THORNode reports another router";
|
|
244
509
|
if (++agreeing >= 2) return null;
|
|
245
510
|
}
|
|
246
|
-
return "two THORNodes could not confirm the inbound vault";
|
|
511
|
+
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));
|
|
247
512
|
}
|
|
248
513
|
});
|
|
249
514
|
}
|
|
@@ -283,23 +548,109 @@ function thorchainProvider(config = {}) {
|
|
|
283
548
|
routers.set(chain, set);
|
|
284
549
|
}
|
|
285
550
|
const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
|
|
286
|
-
const
|
|
551
|
+
const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
|
|
552
|
+
const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
|
|
553
|
+
let inbounds;
|
|
554
|
+
let height;
|
|
555
|
+
let heightError;
|
|
556
|
+
let done = false;
|
|
557
|
+
const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
|
|
558
|
+
const settle = () => {
|
|
559
|
+
if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
|
|
560
|
+
done = true;
|
|
561
|
+
resolve(heightError ? { inbounds, heightError } : { inbounds, height });
|
|
562
|
+
};
|
|
563
|
+
const fail = (error) => {
|
|
564
|
+
if (done) return;
|
|
565
|
+
done = true;
|
|
566
|
+
tip.clear();
|
|
567
|
+
reject(error);
|
|
568
|
+
};
|
|
569
|
+
inboundAddressesOn(host, clientId).then((v) => {
|
|
570
|
+
inbounds = v;
|
|
571
|
+
settle();
|
|
572
|
+
}, fail);
|
|
573
|
+
nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
|
|
574
|
+
(body) => {
|
|
575
|
+
tip.clear();
|
|
576
|
+
const h = lastblockHeight(body);
|
|
577
|
+
if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
|
|
578
|
+
else height = h;
|
|
579
|
+
settle();
|
|
580
|
+
},
|
|
581
|
+
(error) => {
|
|
582
|
+
tip.clear();
|
|
583
|
+
if (!(error instanceof HostUnavailable)) return fail(error);
|
|
584
|
+
heightError = error;
|
|
585
|
+
settle();
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
const confirmText = (v) => {
|
|
590
|
+
switch (v.why) {
|
|
591
|
+
case "halted":
|
|
592
|
+
return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
|
|
593
|
+
case "another-vault":
|
|
594
|
+
return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
|
|
595
|
+
case "not-active":
|
|
596
|
+
return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
|
|
597
|
+
case "flagged":
|
|
598
|
+
return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
|
|
599
|
+
case "another-router":
|
|
600
|
+
return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
|
|
604
|
+
const read = (path) => nodeRead(host, path, clientId, signal, outer);
|
|
605
|
+
const head = await nodeHead(host, read);
|
|
606
|
+
if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
|
|
607
|
+
const verdict = await vaultVerdict(host, read, head.inbound, claim);
|
|
608
|
+
return verdict.ok ? null : confirmText(verdict);
|
|
609
|
+
};
|
|
610
|
+
const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
|
|
611
|
+
const claim = {
|
|
612
|
+
chain: THOR_CHAIN[chain] ?? chain,
|
|
613
|
+
...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
|
|
614
|
+
vault,
|
|
615
|
+
...router !== void 0 ? { router } : {}
|
|
616
|
+
};
|
|
617
|
+
let lagging = false;
|
|
618
|
+
let asked = 0;
|
|
619
|
+
const failures = [];
|
|
287
620
|
for (const host of confirmHosts) {
|
|
288
621
|
if (host === quoteHost) continue;
|
|
289
|
-
|
|
622
|
+
const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
|
|
623
|
+
if (budget < MIN_CONFIRM_TIMEOUT_MS) {
|
|
624
|
+
failures.push({ reason: "upstream_unavailable" });
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
asked++;
|
|
628
|
+
const node = nodeDeadline(budget, outer);
|
|
290
629
|
try {
|
|
291
|
-
|
|
630
|
+
const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
|
|
631
|
+
if (verdict === LAGGING) {
|
|
632
|
+
lagging = true;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
return verdict;
|
|
292
636
|
} catch (error) {
|
|
293
|
-
if (
|
|
637
|
+
if (outer?.aborted) throw error;
|
|
638
|
+
if (error instanceof HostUnavailable) {
|
|
639
|
+
failures.push(refusalOf(error));
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
294
642
|
throw error;
|
|
643
|
+
} finally {
|
|
644
|
+
node.clear();
|
|
295
645
|
}
|
|
296
|
-
const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
|
|
297
|
-
if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
|
|
298
|
-
if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
|
|
299
|
-
if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
|
|
300
|
-
return null;
|
|
301
646
|
}
|
|
302
|
-
|
|
647
|
+
if (lagging) {
|
|
648
|
+
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" } };
|
|
649
|
+
}
|
|
650
|
+
if (asked === 0) {
|
|
651
|
+
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" } };
|
|
652
|
+
}
|
|
653
|
+
return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
|
|
303
654
|
};
|
|
304
655
|
const configuredAffiliate = config.affiliate ?? "";
|
|
305
656
|
const configuredAffiliateBps = config.affiliateBps ?? "";
|
|
@@ -345,24 +696,27 @@ function thorchainProvider(config = {}) {
|
|
|
345
696
|
const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
|
|
346
697
|
const dst = destAsset(params.destChain, params.destToken);
|
|
347
698
|
if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
|
|
348
|
-
params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}
|
|
699
|
+
params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
|
|
349
700
|
return null;
|
|
350
701
|
}
|
|
351
702
|
const amountIn = BigInt(params.amountIn);
|
|
352
703
|
const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
|
|
353
704
|
if (amount1e8 <= 0n) return null;
|
|
354
705
|
if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
|
|
355
|
-
params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals
|
|
706
|
+
params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
|
|
356
707
|
return null;
|
|
357
708
|
}
|
|
358
709
|
const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
|
|
359
710
|
if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > bigintToNumber(MAX_TOLERANCE_BPS)) {
|
|
360
|
-
params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps
|
|
711
|
+
params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
|
|
361
712
|
return null;
|
|
362
713
|
}
|
|
363
714
|
const explicitSlippage = params.slippageBps !== void 0;
|
|
364
715
|
const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
|
|
365
716
|
const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
|
|
717
|
+
let quoteStatus = 0;
|
|
718
|
+
let quoteBodyless = false;
|
|
719
|
+
let quoteBodyAborted = false;
|
|
366
720
|
const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
|
|
367
721
|
const qs = new URLSearchParams({
|
|
368
722
|
from_asset: fromAsset,
|
|
@@ -383,6 +737,9 @@ function thorchainProvider(config = {}) {
|
|
|
383
737
|
}
|
|
384
738
|
if (params.refundAddress) qs.set("refund_address", params.refundAddress);
|
|
385
739
|
const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
|
|
740
|
+
quoteStatus = res.status;
|
|
741
|
+
quoteBodyless = res.body === null && !res.ok;
|
|
742
|
+
quoteBodyAborted = res.body === null && signal?.aborted === true;
|
|
386
743
|
if (Array.isArray(res.body)) return null;
|
|
387
744
|
return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
|
|
388
745
|
};
|
|
@@ -390,13 +747,17 @@ function thorchainProvider(config = {}) {
|
|
|
390
747
|
let checkedMemo = null;
|
|
391
748
|
let inbounds = null;
|
|
392
749
|
let quoteHost = "";
|
|
750
|
+
let quoteHeight = 0;
|
|
751
|
+
let confirmDeadlineAt = 0;
|
|
752
|
+
let heightUnavailable = false;
|
|
753
|
+
const hostFailures = [];
|
|
393
754
|
for (const host of hosts) {
|
|
394
755
|
try {
|
|
395
756
|
if (limitMode === "liquidity_tolerance_bps") {
|
|
396
757
|
q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
|
|
397
758
|
const feeBps = Number(q?.fees?.total_bps);
|
|
398
759
|
if (Number.isFinite(feeBps) && feeBps > bigintToNumber(MAX_TOLERANCE_BPS)) {
|
|
399
|
-
params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${bigintToNumber(MAX_TOLERANCE_BPS) / 100}%)
|
|
760
|
+
params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${bigintToNumber(MAX_TOLERANCE_BPS) / 100}%)`, { reason: "policy_refused" });
|
|
400
761
|
return null;
|
|
401
762
|
}
|
|
402
763
|
} else {
|
|
@@ -413,32 +774,44 @@ function thorchainProvider(config = {}) {
|
|
|
413
774
|
}
|
|
414
775
|
}
|
|
415
776
|
if (q?.error || q?.message) {
|
|
416
|
-
params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}
|
|
777
|
+
params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`, quoteBodyAborted ? httpRefusal2(quoteStatus) : quoteRejection(String(q.error ?? q.message), quoteStatus, quoteBodyless));
|
|
417
778
|
return null;
|
|
418
779
|
}
|
|
419
780
|
if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
|
|
420
|
-
params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)
|
|
781
|
+
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 } : {} });
|
|
421
782
|
return null;
|
|
422
783
|
}
|
|
423
784
|
if (chainId ? !q.router : !!q.router) {
|
|
424
|
-
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");
|
|
785
|
+
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" });
|
|
425
786
|
return null;
|
|
426
787
|
}
|
|
427
|
-
|
|
788
|
+
let memoRefusal = "recipient_mismatch";
|
|
789
|
+
checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
|
|
790
|
+
memoRefusal = why;
|
|
791
|
+
});
|
|
428
792
|
if (!checkedMemo) {
|
|
429
|
-
params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})
|
|
793
|
+
params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
|
|
430
794
|
return null;
|
|
431
795
|
}
|
|
432
796
|
const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
|
|
433
797
|
if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
|
|
434
|
-
params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)
|
|
798
|
+
params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
|
|
435
799
|
return null;
|
|
436
800
|
}
|
|
437
|
-
|
|
801
|
+
const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
|
|
802
|
+
const snapshot = await nodeSnapshot(host, signal);
|
|
803
|
+
if ("heightError" in snapshot) {
|
|
804
|
+
heightUnavailable = true;
|
|
805
|
+
throw snapshot.heightError;
|
|
806
|
+
}
|
|
807
|
+
inbounds = snapshot.inbounds;
|
|
808
|
+
quoteHeight = snapshot.height;
|
|
438
809
|
quoteHost = host;
|
|
810
|
+
confirmDeadlineAt = deadlineAt;
|
|
439
811
|
break;
|
|
440
812
|
} catch (error) {
|
|
441
813
|
if (error instanceof HostUnavailable) {
|
|
814
|
+
hostFailures.push(refusalOf(error));
|
|
442
815
|
q = null;
|
|
443
816
|
checkedMemo = null;
|
|
444
817
|
inbounds = null;
|
|
@@ -448,60 +821,72 @@ function thorchainProvider(config = {}) {
|
|
|
448
821
|
}
|
|
449
822
|
}
|
|
450
823
|
if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
|
|
451
|
-
params.onRefusal?.(
|
|
824
|
+
params.onRefusal?.(
|
|
825
|
+
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",
|
|
826
|
+
inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
|
|
827
|
+
);
|
|
452
828
|
return null;
|
|
453
829
|
}
|
|
454
830
|
const limit1e8 = checkedMemo.limit;
|
|
455
831
|
const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
|
|
456
832
|
const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
|
|
457
833
|
if (paused(srcInbound) || paused(dstInbound)) {
|
|
458
|
-
params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain
|
|
834
|
+
params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
|
|
459
835
|
return null;
|
|
460
836
|
}
|
|
461
837
|
if (!sameTarget(srcInbound?.address, q.inbound_address)) {
|
|
462
|
-
params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
|
|
838
|
+
params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
|
|
463
839
|
return null;
|
|
464
840
|
}
|
|
465
841
|
if (chainId && !sameTarget(srcInbound?.router, q.router)) {
|
|
466
|
-
params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
|
|
842
|
+
params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
|
|
467
843
|
return null;
|
|
468
844
|
}
|
|
469
845
|
if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
|
|
470
|
-
params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)
|
|
846
|
+
params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
|
|
471
847
|
return null;
|
|
472
848
|
}
|
|
473
|
-
const unconfirmed = await confirmedElsewhere(
|
|
849
|
+
const unconfirmed = await confirmedElsewhere(
|
|
850
|
+
quoteHost,
|
|
851
|
+
quoteHeight,
|
|
852
|
+
params.sourceChain,
|
|
853
|
+
params.destChain,
|
|
854
|
+
q.inbound_address,
|
|
855
|
+
chainId ? q.router : void 0,
|
|
856
|
+
confirmDeadlineAt,
|
|
857
|
+
signal
|
|
858
|
+
);
|
|
474
859
|
if (unconfirmed) {
|
|
475
|
-
params.onRefusal?.(`thorchain: ${unconfirmed}
|
|
860
|
+
params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
|
|
476
861
|
return null;
|
|
477
862
|
}
|
|
478
863
|
const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
|
|
479
864
|
if (tooSmall) {
|
|
480
|
-
params.onRefusal?.(`thorchain: ${tooSmall}
|
|
865
|
+
params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
|
|
481
866
|
return null;
|
|
482
867
|
}
|
|
483
868
|
const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
|
|
484
869
|
if (outUnits <= 0n) {
|
|
485
|
-
params.onRefusal?.("thorchain: expected output rounds to zero");
|
|
870
|
+
params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
|
|
486
871
|
return null;
|
|
487
872
|
}
|
|
488
873
|
const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
|
|
489
874
|
const minUnits = memoMinUnits;
|
|
490
875
|
if (minUnits <= 0n || minUnits > outUnits) {
|
|
491
|
-
params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}
|
|
876
|
+
params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
|
|
492
877
|
return null;
|
|
493
878
|
}
|
|
494
879
|
if (params.slippageBps !== void 0) {
|
|
495
880
|
const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
|
|
496
881
|
if (minUnits < slippageFloor) {
|
|
497
|
-
params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)
|
|
882
|
+
params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
|
|
498
883
|
return null;
|
|
499
884
|
}
|
|
500
885
|
}
|
|
501
886
|
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
502
887
|
const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
|
|
503
888
|
if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
|
|
504
|
-
params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute
|
|
889
|
+
params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
|
|
505
890
|
return null;
|
|
506
891
|
}
|
|
507
892
|
const assetAddr = src.contract ? src.contract : NATIVE;
|
|
@@ -557,7 +942,7 @@ function thorchainProvider(config = {}) {
|
|
|
557
942
|
if (!quote.txRequest) {
|
|
558
943
|
const sourceChain = quote.providerData?.sourceChain ?? "";
|
|
559
944
|
if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
|
|
560
|
-
throw
|
|
945
|
+
throw refusal("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
|
|
561
946
|
}
|
|
562
947
|
return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
|
|
563
948
|
}
|
|
@@ -574,67 +959,96 @@ function thorchainProvider(config = {}) {
|
|
|
574
959
|
async preflightDeposit(quote, tx) {
|
|
575
960
|
const data = quote.providerData;
|
|
576
961
|
if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
|
|
577
|
-
throw
|
|
962
|
+
throw refusal("policy_refused", "thorchain: quote is missing execution-bound metadata");
|
|
578
963
|
}
|
|
579
964
|
const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
|
|
580
|
-
if (evmSource && !data.router) throw
|
|
581
|
-
if (!evmSource && !isDepositSource(data.sourceChain)) throw
|
|
965
|
+
if (evmSource && !data.router) throw refusal("policy_refused", "thorchain: quote is missing execution-bound metadata");
|
|
966
|
+
if (!evmSource && !isDepositSource(data.sourceChain)) throw refusal("unsupported_pair", "thorchain: source chain has no executor");
|
|
582
967
|
if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
|
|
583
|
-
throw
|
|
968
|
+
throw refusal("quote_expired", "thorchain: quote expired before broadcast");
|
|
584
969
|
}
|
|
585
|
-
if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw
|
|
970
|
+
if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw refusal("vault_mismatch", "thorchain: the quote's router is not one this module trusts");
|
|
586
971
|
let inbounds = null;
|
|
587
972
|
let snapshotHost = "";
|
|
973
|
+
let snapshotHeight = 0;
|
|
974
|
+
let confirmDeadlineAt = 0;
|
|
975
|
+
let heightUnavailable = false;
|
|
976
|
+
const hostFailures = [];
|
|
588
977
|
for (const host of hosts) {
|
|
978
|
+
const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
|
|
589
979
|
try {
|
|
590
|
-
|
|
980
|
+
const snapshot = await nodeSnapshot(host);
|
|
981
|
+
if ("heightError" in snapshot) {
|
|
982
|
+
heightUnavailable = true;
|
|
983
|
+
hostFailures.push(refusalOf(snapshot.heightError));
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
inbounds = snapshot.inbounds;
|
|
987
|
+
snapshotHeight = snapshot.height;
|
|
591
988
|
snapshotHost = host;
|
|
989
|
+
confirmDeadlineAt = deadlineAt;
|
|
592
990
|
break;
|
|
593
991
|
} catch (error) {
|
|
992
|
+
inbounds = null;
|
|
594
993
|
if (!(error instanceof HostUnavailable)) throw error;
|
|
994
|
+
hostFailures.push(refusalOf(error));
|
|
595
995
|
}
|
|
596
996
|
}
|
|
597
|
-
if (!inbounds)
|
|
997
|
+
if (!inbounds) {
|
|
998
|
+
throw withReason2(new Error(
|
|
999
|
+
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"
|
|
1000
|
+
), nodesFailed(hostFailures));
|
|
1001
|
+
}
|
|
598
1002
|
const confirm = async () => {
|
|
599
|
-
const unconfirmed = await confirmedElsewhere(
|
|
600
|
-
|
|
1003
|
+
const unconfirmed = await confirmedElsewhere(
|
|
1004
|
+
snapshotHost,
|
|
1005
|
+
snapshotHeight,
|
|
1006
|
+
data.sourceChain,
|
|
1007
|
+
data.destChain,
|
|
1008
|
+
data.inboundAddress,
|
|
1009
|
+
evmSource ? data.router : void 0,
|
|
1010
|
+
confirmDeadlineAt
|
|
1011
|
+
);
|
|
1012
|
+
if (unconfirmed) throw withReason2(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
|
|
601
1013
|
};
|
|
602
1014
|
const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
|
|
603
1015
|
const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
|
|
604
|
-
if (paused(src) || paused(dst)) throw
|
|
605
|
-
if (!sameTarget(src?.address, data.inboundAddress)) throw
|
|
1016
|
+
if (paused(src) || paused(dst)) throw refusal("chain_halted", "thorchain: source or destination chain trading is paused");
|
|
1017
|
+
if (!sameTarget(src?.address, data.inboundAddress)) throw refusal("vault_mismatch", "thorchain: inbound vault rotated after quote");
|
|
606
1018
|
if (!evmSource) {
|
|
607
1019
|
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) {
|
|
608
|
-
throw
|
|
1020
|
+
throw refusal("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
|
|
609
1021
|
}
|
|
610
1022
|
if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
|
|
611
|
-
throw
|
|
1023
|
+
throw refusal(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
|
|
612
1024
|
}
|
|
613
1025
|
const srcAsset = ccAssetByToken(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
|
|
614
|
-
if (!srcAsset) throw
|
|
1026
|
+
if (!srcAsset) throw refusal("unsupported_pair", "thorchain: unknown source asset");
|
|
615
1027
|
const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
|
|
616
1028
|
const tooSmall = belowThorMinimums(amount1e8, src, null);
|
|
617
|
-
if (tooSmall) throw
|
|
1029
|
+
if (tooSmall) throw refusal("amount_too_small", `thorchain: ${tooSmall}`);
|
|
618
1030
|
await confirm();
|
|
619
1031
|
return;
|
|
620
1032
|
}
|
|
621
1033
|
if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
|
|
622
|
-
throw
|
|
1034
|
+
throw refusal("vault_mismatch", "thorchain: router rotated after quote");
|
|
623
1035
|
}
|
|
624
|
-
if (!tx.data) throw
|
|
1036
|
+
if (!tx.data) throw refusal("policy_refused", "thorchain: missing depositWithExpiry calldata");
|
|
625
1037
|
let decoded;
|
|
626
1038
|
try {
|
|
627
1039
|
decoded = decodeFunctionData({ abi: ROUTER_ABI, data: tx.data });
|
|
628
1040
|
} catch {
|
|
629
|
-
throw
|
|
1041
|
+
throw refusal("policy_refused", "thorchain: malformed depositWithExpiry calldata");
|
|
630
1042
|
}
|
|
631
1043
|
const args = decoded.args;
|
|
632
1044
|
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]) {
|
|
633
|
-
|
|
1045
|
+
const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
|
|
1046
|
+
throw refusal(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
|
|
634
1047
|
}
|
|
635
1048
|
const tokenSource = !sameTarget(data.assetAddress, NATIVE);
|
|
636
1049
|
if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
|
|
637
|
-
|
|
1050
|
+
const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
|
|
1051
|
+
throw refusal(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
|
|
638
1052
|
}
|
|
639
1053
|
await confirm();
|
|
640
1054
|
},
|
|
@@ -688,8 +1102,9 @@ function thorchainProvider(config = {}) {
|
|
|
688
1102
|
}
|
|
689
1103
|
|
|
690
1104
|
export {
|
|
1105
|
+
THORNODE_CONFIRM_PATHS,
|
|
691
1106
|
THORCHAIN_ROUTERS,
|
|
692
1107
|
thorchainPins,
|
|
693
1108
|
thorchainProvider
|
|
694
1109
|
};
|
|
695
|
-
//# sourceMappingURL=chunk-
|
|
1110
|
+
//# sourceMappingURL=chunk-EIAXGWNG.js.map
|