@anywalletkit/protocol-swap2-thorchain 0.3.4 → 0.4.0
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-AYP7O2NT.js} +387 -41
- package/dist/chunk-AYP7O2NT.js.map +1 -0
- package/dist/{chunk-QLDZS2KO.js → chunk-LP5BU2JS.js} +2 -2
- package/dist/index.cjs +386 -40
- 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 +340 -28
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.js +2 -2
- package/dist/provider.cjs +386 -40
- package/dist/provider.cjs.map +1 -1
- package/dist/provider.d.cts +58 -2
- package/dist/provider.d.ts +58 -2
- package/dist/provider.js +3 -1
- package/package.json +4 -4
- package/dist/chunk-LYJOENYE.js.map +0 -1
- /package/dist/{chunk-QLDZS2KO.js.map → chunk-LP5BU2JS.js.map} +0 -0
package/README.md
CHANGED
|
@@ -28,12 +28,96 @@ nodes of different operators (Liquify's gateway and the IBS team's node, both ch
|
|
|
28
28
|
operator; a local mock needs a second mock there). Node URLs must be `https:` (`allowInsecureNodeUrl` for local
|
|
29
29
|
development).
|
|
30
30
|
|
|
31
|
+
### How a second node confirms the vault
|
|
32
|
+
|
|
33
|
+
THORChain keeps several asgard vaults active and its inbound address moves among them every few minutes, so a node a
|
|
34
|
+
few blocks behind the quoting node often still names the previous one. A confirming node agrees when the quoted
|
|
35
|
+
vault is:
|
|
36
|
+
|
|
37
|
+
- its current inbound address for the source chain (`/thorchain/inbound_addresses`), or
|
|
38
|
+
- that chain's address of a vault it lists with status `ActiveVault` (`/thorchain/vaults/asgard`, chain matched
|
|
39
|
+
exactly; EVM addresses compare case-insensitively, others exactly) that its mimir (`/thorchain/mimir`) does not
|
|
40
|
+
flag `CompromisedVault-<pubkey>` or `SolvencyHaltVault-<pubkey>-<chain>`. THORNode leaves such vaults out of inbound
|
|
41
|
+
election and does not credit an inbound to a compromised vault.
|
|
42
|
+
|
|
43
|
+
A `RetiringVault` (or any other status) never confirms. The rest stays as strict: on EVM the router in the node's
|
|
44
|
+
`inbound_addresses` must be the quote's; a halt or trading pause on the source or the destination chain refuses; the
|
|
45
|
+
calldata must still pay exactly the quoted vault.
|
|
46
|
+
|
|
47
|
+
A confirming node is skipped for the next one when it trails the quoting node's THORChain height
|
|
48
|
+
(`/thorchain/lastblock`, the `thorchain` field) by more than `confirmMaxLagBlocks` (default 50 ≈ 5 minutes), does not
|
|
49
|
+
answer within `confirmTimeoutMs` (default 2500 ms), redirects (also when the runtime followed the redirect itself, as
|
|
50
|
+
React Native's fetch does: `res.url` must be the URL asked), or answers in another shape. When every node trails, the
|
|
51
|
+
refusal says so ("no confirming node is within 50 blocks …"); when none answers, it is "no second node answered to
|
|
52
|
+
confirm the inbound vault".
|
|
53
|
+
|
|
54
|
+
**Another shape** includes a field of another type. The type of every field read is checked (THORNode's own types):
|
|
55
|
+
in `inbound_addresses` each entry is an object with a string `chain`, `address`, `router` and `dust_threshold` are
|
|
56
|
+
strings and `halted`, `global_trading_paused` and `chain_trading_paused` booleans when present; in `vaults/asgard`
|
|
57
|
+
`status` is a string, `pub_key` a `thorpub1…` string, `addresses` a list of objects with string `chain` and `address`;
|
|
58
|
+
every `mimir` value is a number or a string (int64), and a vault flag an int64; `lastblock`'s `thorchain` is a
|
|
59
|
+
non-negative int64. One such field anywhere in an answer (another chain's entry or another vault included) skips
|
|
60
|
+
the confirming node, and `verifyBuild` skips it the same way. The quoting node's `inbound_addresses` and `lastblock`
|
|
61
|
+
are read the same way: another shape makes that node unavailable and the next `nodeUrl` host is asked, with the
|
|
62
|
+
existing refusals when none is left ("inbound addresses unavailable from every node", "THORChain height
|
|
63
|
+
(/thorchain/lastblock) unavailable from every node that answered the inbound addresses"; before broadcast "cannot
|
|
64
|
+
refresh inbound addresses before broadcast").
|
|
65
|
+
|
|
66
|
+
**Time.** The vault check has its own 8 s budget. It starts when the quoting node's quote is in hand (before
|
|
67
|
+
broadcast: when a node is asked to refresh the inbound snapshot) and covers that node's `inbound_addresses` and
|
|
68
|
+
`lastblock` reads and every confirming node, each getting `confirmTimeoutMs` or what is left; time a failed host took
|
|
69
|
+
before does not count, and no confirming node is asked with less than 100 ms left. When the budget runs out before
|
|
70
|
+
any confirming node was asked, the refusal is "the 8 s confirmation budget ran out before a second node could be asked to confirm the
|
|
71
|
+
inbound vault — retry". `confirmTimeoutMs` bounds confirming nodes only: each read of the quoting node (the quote,
|
|
72
|
+
`inbound_addresses`, `lastblock`) gets 8 s whatever it is. When a node answers `inbound_addresses` but not
|
|
73
|
+
`lastblock`, the refusal names it: "THORChain height (/thorchain/lastblock) unavailable from every node that answered
|
|
74
|
+
the inbound addresses" (before broadcast: "cannot read THORChain height (/thorchain/lastblock) before broadcast from
|
|
75
|
+
any node that answered the inbound addresses"). Worst case without a caller signal: each `nodeUrl` host takes at most
|
|
76
|
+
8 s for the quote (16 s when a quote without a slippage is retried with a wider tolerance) plus 8 s for its snapshot,
|
|
77
|
+
and the host that quotes is confirmed within the 8 s that started when its quote was in hand: hosts × 16 s, or hosts ×
|
|
78
|
+
24 s with the retry (32 s / 48 s with the two default hosts); `preflightDeposit` hosts × 8 s. A caller's AbortSignal
|
|
79
|
+
(the router passes one: 8 s per provider, 25 s when it is the only one) replaces the 8 s bound of the quote request;
|
|
80
|
+
when it aborts, the quote request, the quoting node's `lastblock` read and the confirming reads stop at once, and its
|
|
81
|
+
`inbound_addresses` read keeps its own 8 s.
|
|
82
|
+
|
|
83
|
+
**Requests a confirming node gets** (`THORNODE_CONFIRM_PATHS`), all `GET`, no query string, no body, headers
|
|
84
|
+
`accept` and `x-client-id` only. A proxy in front of a confirming node can pass exactly these four and refuse
|
|
85
|
+
anything else:
|
|
86
|
+
|
|
87
|
+
| Path | When |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `/thorchain/lastblock` | every confirmation |
|
|
90
|
+
| `/thorchain/inbound_addresses` | every confirmation |
|
|
91
|
+
| `/thorchain/vaults/asgard` | when the node's current inbound is not the quoted vault |
|
|
92
|
+
| `/thorchain/mimir` | when the node's current inbound is not the quoted vault |
|
|
93
|
+
|
|
94
|
+
The quoting node (`nodeUrl`) gets `/thorchain/quote/swap?from_asset=…&to_asset=…&amount=…&destination=…` with
|
|
95
|
+
`tolerance_bps` or `liquidity_tolerance_bps`, `streaming_quantity=1` and, when set, `refund_address`, `affiliate`,
|
|
96
|
+
`affiliate_bps`; then `/thorchain/inbound_addresses` and `GET /thorchain/lastblock` (no query string, so a proxy in
|
|
97
|
+
front of a `nodeUrl` must pass it too); `trackIntent` reads `/thorchain/tx/status/<HASH>` and `/thorchain/tx/<HASH>`.
|
|
98
|
+
The quote's `inbound_addresses` read and the quote itself ask for `cache: "no-store"`, which React Native's fetch
|
|
99
|
+
sends as an extra `?_=<time>` query; `lastblock` and the confirming reads set no cache option.
|
|
100
|
+
|
|
31
101
|
Behind `remoteProvider`, the app checks the backend's build itself: `remoteProvider({name: "thorchain", baseUrl,
|
|
32
102
|
...thorchainPins()})` pins the routers and runs `verifyBuild` on the client — the vault in the router call confirmed
|
|
33
103
|
by two nodes, the source asset and exact input, and the memo's destination, refund and limit against the app's own
|
|
34
104
|
request (pass the backend's `affiliate` / `affiliateBps`). The pins open the stub to the Tron deposit route
|
|
35
105
|
(`depositAddressRoutes`): its vault is the one `verifyBuild` confirms.
|
|
36
106
|
|
|
107
|
+
`verifyBuild` confirms the vault by the same rule as the provider's confirming nodes, so an inbound rotation between
|
|
108
|
+
the backend's quote and the device's check does not refuse: current inbound or a non-flagged `ActiveVault` (never
|
|
109
|
+
`RetiringVault` or another status), both chains open, on EVM the node's router equal to the call's. It asks every
|
|
110
|
+
`nodeUrl` node (default: the two public nodes) in parallel on the four `THORNODE_CONFIRM_PATHS` (the same GETs, so the
|
|
111
|
+
same proxy rules), each within `confirmTimeoutMs` (default 2500 ms, so the check takes at most that); a node that
|
|
112
|
+
fails, redirects, answers another shape (a field of another type included, as above), is slower, or trails the
|
|
113
|
+
highest THORChain height the nodes report by more than `confirmMaxLagBlocks` (default 50) is skipped. In `nodeUrl`
|
|
114
|
+
order a node's refusal refuses and two confirming nodes pass. Refusals: "a THORNode reports another inbound vault
|
|
115
|
+
than `<vault>`", "a THORNode reports another router",
|
|
116
|
+
"`<CHAIN>` is halted / paused" (now also the destination chain), "a THORNode lists the inbound vault as `<Status>`, not
|
|
117
|
+
ActiveVault", "a THORNode's mimir flags the inbound vault as compromised" (or `insolvent`), "two THORNodes within 50
|
|
118
|
+
blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)", "two
|
|
119
|
+
THORNodes could not confirm the inbound vault".
|
|
120
|
+
|
|
37
121
|
## Activity
|
|
38
122
|
|
|
39
123
|
Routers and Asgard vaults rotate, so `describeActivity(tx, ctx)` matches no
|
|
@@ -10,6 +10,200 @@ import { decodeFunctionData, encodeFunctionData } from "viem";
|
|
|
10
10
|
import { timeoutSignal } from "@anywalletkit/protocol-swap2-core/util";
|
|
11
11
|
import { ccAsset, ccAssetByToken } from "@anywalletkit/swap2-assets";
|
|
12
12
|
import { bigintToNumber } from "@anywalletkit/core";
|
|
13
|
+
|
|
14
|
+
// src/confirm.ts
|
|
15
|
+
var THORNODE_CONFIRM_PATHS = Object.freeze({
|
|
16
|
+
lastblock: "/thorchain/lastblock",
|
|
17
|
+
inboundAddresses: "/thorchain/inbound_addresses",
|
|
18
|
+
asgardVaults: "/thorchain/vaults/asgard",
|
|
19
|
+
mimir: "/thorchain/mimir"
|
|
20
|
+
});
|
|
21
|
+
var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
|
|
22
|
+
var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
|
|
23
|
+
var MIN_CONFIRM_TIMEOUT_MS = 100;
|
|
24
|
+
var MAX_CONFIRM_TIMEOUT_MS = 8e3;
|
|
25
|
+
function confirmOptions(o) {
|
|
26
|
+
const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
|
|
27
|
+
if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
|
|
28
|
+
throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
|
|
29
|
+
}
|
|
30
|
+
const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
|
|
31
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
|
|
32
|
+
throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
|
|
33
|
+
}
|
|
34
|
+
return { maxLagBlocks, timeoutMs };
|
|
35
|
+
}
|
|
36
|
+
var HostUnavailable = class extends Error {
|
|
37
|
+
constructor(host, reason) {
|
|
38
|
+
super(`thorchain: ${host} unavailable (${reason})`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function paused(entry) {
|
|
42
|
+
return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
|
|
43
|
+
}
|
|
44
|
+
function sameTarget(a, b) {
|
|
45
|
+
if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
|
|
46
|
+
return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
47
|
+
}
|
|
48
|
+
function nodeDeadline(ms, outer) {
|
|
49
|
+
const ctrl = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
|
|
51
|
+
const onAbort = () => ctrl.abort();
|
|
52
|
+
if (outer) {
|
|
53
|
+
if (outer.aborted) ctrl.abort();
|
|
54
|
+
else outer.addEventListener("abort", onAbort);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
signal: ctrl.signal,
|
|
58
|
+
clear() {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
outer?.removeEventListener("abort", onAbort);
|
|
61
|
+
if (!ctrl.signal.aborted) ctrl.abort();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function sameRequestUrl(actual, expected) {
|
|
66
|
+
if (actual === void 0 || actual === null || actual === "") return true;
|
|
67
|
+
if (typeof actual !== "string") return false;
|
|
68
|
+
try {
|
|
69
|
+
const a = new URL(actual);
|
|
70
|
+
const e = new URL(expected);
|
|
71
|
+
return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function nodeRead(host, path, clientId, signal, outer) {
|
|
77
|
+
const url = `${host}${path}`;
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (outer?.aborted) throw error;
|
|
83
|
+
throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
|
|
84
|
+
}
|
|
85
|
+
if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
|
|
86
|
+
if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
|
|
87
|
+
try {
|
|
88
|
+
return await res.json();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (outer?.aborted) throw error;
|
|
91
|
+
throw new HostUnavailable(host, `${path} is not JSON`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function int64(v) {
|
|
95
|
+
if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
|
|
96
|
+
if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
100
|
+
var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
|
|
101
|
+
var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
|
|
102
|
+
function inboundAddresses(body) {
|
|
103
|
+
if (!Array.isArray(body)) return null;
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const e of body) {
|
|
106
|
+
if (!isObject(e) || typeof e.chain !== "string") return null;
|
|
107
|
+
const entry = { chain: e.chain };
|
|
108
|
+
for (const k of INBOUND_STRINGS) {
|
|
109
|
+
const v = e[k];
|
|
110
|
+
if (v === void 0) continue;
|
|
111
|
+
if (typeof v !== "string") return null;
|
|
112
|
+
entry[k] = v;
|
|
113
|
+
}
|
|
114
|
+
for (const k of INBOUND_FLAGS) {
|
|
115
|
+
const v = e[k];
|
|
116
|
+
if (v === void 0) continue;
|
|
117
|
+
if (typeof v !== "boolean") return null;
|
|
118
|
+
entry[k] = v;
|
|
119
|
+
}
|
|
120
|
+
out.push(entry);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function lastblockHeight(body) {
|
|
125
|
+
if (!Array.isArray(body) || body.length === 0) return null;
|
|
126
|
+
let height = -1;
|
|
127
|
+
for (const entry of body) {
|
|
128
|
+
if (!isObject(entry)) return null;
|
|
129
|
+
const h = int64(entry.thorchain);
|
|
130
|
+
if (h === null || h < 0) return null;
|
|
131
|
+
if (h > height) height = h;
|
|
132
|
+
}
|
|
133
|
+
return height;
|
|
134
|
+
}
|
|
135
|
+
function asgardVaults(body) {
|
|
136
|
+
if (!Array.isArray(body)) return null;
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const v of body) {
|
|
139
|
+
if (!isObject(v)) return null;
|
|
140
|
+
if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
|
|
141
|
+
if (v.type !== void 0 && v.type !== "AsgardVault") return null;
|
|
142
|
+
if (!Array.isArray(v.addresses)) return null;
|
|
143
|
+
const addresses = [];
|
|
144
|
+
for (const a of v.addresses) {
|
|
145
|
+
if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
|
|
146
|
+
addresses.push({ chain: a.chain, address: a.address });
|
|
147
|
+
}
|
|
148
|
+
out.push({ pubKey: v.pub_key, status: v.status, addresses });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function mimirFlags(body) {
|
|
153
|
+
if (!isObject(body) || Object.keys(body).length === 0) return null;
|
|
154
|
+
const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
|
|
155
|
+
if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
|
|
156
|
+
return (pubKey) => {
|
|
157
|
+
const pub = pubKey.toUpperCase();
|
|
158
|
+
const compromisedKey = `COMPROMISEDVAULT-${pub}`;
|
|
159
|
+
const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
|
|
160
|
+
let flag = null;
|
|
161
|
+
for (const [key, value] of keys) {
|
|
162
|
+
const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
|
|
163
|
+
if (!kind) continue;
|
|
164
|
+
const n = int64(value);
|
|
165
|
+
if (n === null) return "malformed";
|
|
166
|
+
if (n > 0 && flag !== "compromised") flag = kind;
|
|
167
|
+
}
|
|
168
|
+
return flag;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function nodeHead(host, read) {
|
|
172
|
+
const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
|
|
173
|
+
const height = lastblockHeight(lastblock);
|
|
174
|
+
if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
|
|
175
|
+
return { height, inbound };
|
|
176
|
+
}
|
|
177
|
+
async function vaultVerdict(host, read, inbound, claim) {
|
|
178
|
+
if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
|
|
179
|
+
const entries = inboundAddresses(inbound);
|
|
180
|
+
if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
|
|
181
|
+
const entry = entries.find((e) => e.chain === claim.chain);
|
|
182
|
+
if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
|
|
183
|
+
if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
|
|
184
|
+
return { ok: false, why: "halted", chain: claim.destChain };
|
|
185
|
+
}
|
|
186
|
+
if (!sameTarget(entry?.address, claim.vault)) {
|
|
187
|
+
const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
|
|
188
|
+
const vaults = asgardVaults(asgardBody);
|
|
189
|
+
if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
|
|
190
|
+
const flags = mimirFlags(mimirBody);
|
|
191
|
+
if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
|
|
192
|
+
const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
|
|
193
|
+
const active = holders.find((v) => v.status === "ActiveVault");
|
|
194
|
+
if (!active) {
|
|
195
|
+
if (!holders.length) return { ok: false, why: "another-vault" };
|
|
196
|
+
return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
|
|
197
|
+
}
|
|
198
|
+
const flag = flags(active.pubKey);
|
|
199
|
+
if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
|
|
200
|
+
if (flag) return { ok: false, why: "flagged", flag };
|
|
201
|
+
}
|
|
202
|
+
if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/provider.ts
|
|
13
207
|
var HOSTS = [
|
|
14
208
|
"https://gateway.liquify.com/chain/thorchain_api",
|
|
15
209
|
"https://thorchain.ibs.team/api"
|
|
@@ -25,6 +219,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
|
|
|
25
219
|
var TOLERANCE_BPS = "300";
|
|
26
220
|
var MAX_TOLERANCE_BPS = 500n;
|
|
27
221
|
var QUOTE_VALIDITY_MS = 5 * 6e4;
|
|
222
|
+
var NODE_READ_TIMEOUT_MS = 8e3;
|
|
223
|
+
var CONFIRM_BUDGET_MS = 8e3;
|
|
224
|
+
var LAGGING = /* @__PURE__ */ Symbol("lagging");
|
|
28
225
|
var THOR_CHAIN = {
|
|
29
226
|
"eip155:1": "ETH",
|
|
30
227
|
"eip155:8453": "BASE",
|
|
@@ -105,18 +302,13 @@ function destAsset(destChain, destToken) {
|
|
|
105
302
|
const asset = thorAsset(destChain, a.symbol);
|
|
106
303
|
return asset ? { asset, decimals: a.decimals } : void 0;
|
|
107
304
|
}
|
|
108
|
-
var HostUnavailable = class extends Error {
|
|
109
|
-
constructor(host, reason) {
|
|
110
|
-
super(`thorchain: ${host} unavailable (${reason})`);
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
305
|
async function hostGet(host, path, clientId, signal) {
|
|
114
306
|
let res;
|
|
115
307
|
try {
|
|
116
308
|
res = await fetch(`${host}${path}`, {
|
|
117
309
|
headers: { accept: "application/json", "x-client-id": clientId },
|
|
118
310
|
cache: "no-store",
|
|
119
|
-
signal: signal ?? timeoutSignal(
|
|
311
|
+
signal: signal ?? timeoutSignal(NODE_READ_TIMEOUT_MS)
|
|
120
312
|
});
|
|
121
313
|
} catch (error) {
|
|
122
314
|
if (signal?.aborted) throw error;
|
|
@@ -151,14 +343,9 @@ function belowThorMinimums(amount1e8, inbound, quote) {
|
|
|
151
343
|
async function inboundAddressesOn(host, clientId) {
|
|
152
344
|
const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
|
|
153
345
|
if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
|
|
158
|
-
}
|
|
159
|
-
function sameTarget(a, b) {
|
|
160
|
-
if (!a || !b) return false;
|
|
161
|
-
return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
346
|
+
const entries = inboundAddresses(res.body);
|
|
347
|
+
if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
|
|
348
|
+
return entries;
|
|
162
349
|
}
|
|
163
350
|
function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
|
|
164
351
|
if (memo.includes("|")) return null;
|
|
@@ -188,6 +375,25 @@ function thorchainPins(opts = {}) {
|
|
|
188
375
|
const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
|
|
189
376
|
for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
|
|
190
377
|
const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
|
|
378
|
+
const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
|
|
379
|
+
const askNode = async (host, claim) => {
|
|
380
|
+
const node = nodeDeadline(timeoutMs);
|
|
381
|
+
const read = (path) => nodeRead(host, path, clientId, node.signal);
|
|
382
|
+
try {
|
|
383
|
+
const head = await nodeHead(host, read);
|
|
384
|
+
try {
|
|
385
|
+
return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (error instanceof HostUnavailable) return { height: head.height, verdict: null };
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error instanceof HostUnavailable) return null;
|
|
392
|
+
throw error;
|
|
393
|
+
} finally {
|
|
394
|
+
node.clear();
|
|
395
|
+
}
|
|
396
|
+
};
|
|
191
397
|
const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
|
|
192
398
|
return Object.freeze({
|
|
193
399
|
callTargets: routersFor,
|
|
@@ -228,22 +434,36 @@ function thorchainPins(opts = {}) {
|
|
|
228
434
|
if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
|
|
229
435
|
if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
|
|
230
436
|
if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
|
|
437
|
+
const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
|
|
438
|
+
const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
|
|
439
|
+
const views = await Promise.all(nodes.map((host) => askNode(host, claim)));
|
|
440
|
+
const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
|
|
231
441
|
let agreeing = 0;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
442
|
+
let lagging = false;
|
|
443
|
+
for (const view of views) {
|
|
444
|
+
if (!view?.verdict) continue;
|
|
445
|
+
if (top - view.height > maxLagBlocks) {
|
|
446
|
+
lagging = true;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const v = view.verdict;
|
|
450
|
+
if (!v.ok) {
|
|
451
|
+
switch (v.why) {
|
|
452
|
+
case "halted":
|
|
453
|
+
return `${v.chain} is halted / paused`;
|
|
454
|
+
case "another-vault":
|
|
455
|
+
return `a THORNode reports another inbound vault than ${vault}`;
|
|
456
|
+
case "not-active":
|
|
457
|
+
return `a THORNode lists the inbound vault as ${v.status}, not ActiveVault`;
|
|
458
|
+
case "flagged":
|
|
459
|
+
return `a THORNode's mimir flags the inbound vault as ${v.flag}`;
|
|
460
|
+
case "another-router":
|
|
461
|
+
return "a THORNode reports another router";
|
|
462
|
+
}
|
|
239
463
|
}
|
|
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
464
|
if (++agreeing >= 2) return null;
|
|
245
465
|
}
|
|
246
|
-
return "two THORNodes could not confirm the inbound vault";
|
|
466
|
+
return lagging ? `two THORNodes within ${maxLagBlocks} blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)` : "two THORNodes could not confirm the inbound vault";
|
|
247
467
|
}
|
|
248
468
|
});
|
|
249
469
|
}
|
|
@@ -283,21 +503,100 @@ function thorchainProvider(config = {}) {
|
|
|
283
503
|
routers.set(chain, set);
|
|
284
504
|
}
|
|
285
505
|
const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
|
|
286
|
-
const
|
|
506
|
+
const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
|
|
507
|
+
const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
|
|
508
|
+
let inbounds;
|
|
509
|
+
let height;
|
|
510
|
+
let heightError;
|
|
511
|
+
let done = false;
|
|
512
|
+
const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
|
|
513
|
+
const settle = () => {
|
|
514
|
+
if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
|
|
515
|
+
done = true;
|
|
516
|
+
resolve(heightError ? { inbounds, heightError } : { inbounds, height });
|
|
517
|
+
};
|
|
518
|
+
const fail = (error) => {
|
|
519
|
+
if (done) return;
|
|
520
|
+
done = true;
|
|
521
|
+
tip.clear();
|
|
522
|
+
reject(error);
|
|
523
|
+
};
|
|
524
|
+
inboundAddressesOn(host, clientId).then((v) => {
|
|
525
|
+
inbounds = v;
|
|
526
|
+
settle();
|
|
527
|
+
}, fail);
|
|
528
|
+
nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
|
|
529
|
+
(body) => {
|
|
530
|
+
tip.clear();
|
|
531
|
+
const h = lastblockHeight(body);
|
|
532
|
+
if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
|
|
533
|
+
else height = h;
|
|
534
|
+
settle();
|
|
535
|
+
},
|
|
536
|
+
(error) => {
|
|
537
|
+
tip.clear();
|
|
538
|
+
if (!(error instanceof HostUnavailable)) return fail(error);
|
|
539
|
+
heightError = error;
|
|
540
|
+
settle();
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
const confirmText = (v) => {
|
|
545
|
+
switch (v.why) {
|
|
546
|
+
case "halted":
|
|
547
|
+
return `${v.chain} is halted / paused on the confirming node`;
|
|
548
|
+
case "another-vault":
|
|
549
|
+
return "a second node reports another inbound vault \u2014 refused until they agree";
|
|
550
|
+
case "not-active":
|
|
551
|
+
return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
|
|
552
|
+
case "flagged":
|
|
553
|
+
return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
|
|
554
|
+
case "another-router":
|
|
555
|
+
return "a second node reports another router \u2014 refused until they agree";
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
|
|
559
|
+
const read = (path) => nodeRead(host, path, clientId, signal, outer);
|
|
560
|
+
const head = await nodeHead(host, read);
|
|
561
|
+
if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
|
|
562
|
+
const verdict = await vaultVerdict(host, read, head.inbound, claim);
|
|
563
|
+
return verdict.ok ? null : confirmText(verdict);
|
|
564
|
+
};
|
|
565
|
+
const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
|
|
566
|
+
const claim = {
|
|
567
|
+
chain: THOR_CHAIN[chain] ?? chain,
|
|
568
|
+
...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
|
|
569
|
+
vault,
|
|
570
|
+
...router !== void 0 ? { router } : {}
|
|
571
|
+
};
|
|
572
|
+
let lagging = false;
|
|
573
|
+
let asked = 0;
|
|
287
574
|
for (const host of confirmHosts) {
|
|
288
575
|
if (host === quoteHost) continue;
|
|
289
|
-
|
|
576
|
+
const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
|
|
577
|
+
if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
|
|
578
|
+
asked++;
|
|
579
|
+
const node = nodeDeadline(budget, outer);
|
|
290
580
|
try {
|
|
291
|
-
|
|
581
|
+
const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
|
|
582
|
+
if (verdict === LAGGING) {
|
|
583
|
+
lagging = true;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
return verdict;
|
|
292
587
|
} catch (error) {
|
|
588
|
+
if (outer?.aborted) throw error;
|
|
293
589
|
if (error instanceof HostUnavailable) continue;
|
|
294
590
|
throw error;
|
|
591
|
+
} finally {
|
|
592
|
+
node.clear();
|
|
295
593
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
594
|
+
}
|
|
595
|
+
if (lagging) {
|
|
596
|
+
return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
|
|
597
|
+
}
|
|
598
|
+
if (asked === 0) {
|
|
599
|
+
return `the ${CONFIRM_BUDGET_MS / 1e3} s confirmation budget ran out before a second node could be asked to confirm the inbound vault \u2014 retry`;
|
|
301
600
|
}
|
|
302
601
|
return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
|
|
303
602
|
};
|
|
@@ -390,6 +689,9 @@ function thorchainProvider(config = {}) {
|
|
|
390
689
|
let checkedMemo = null;
|
|
391
690
|
let inbounds = null;
|
|
392
691
|
let quoteHost = "";
|
|
692
|
+
let quoteHeight = 0;
|
|
693
|
+
let confirmDeadlineAt = 0;
|
|
694
|
+
let heightUnavailable = false;
|
|
393
695
|
for (const host of hosts) {
|
|
394
696
|
try {
|
|
395
697
|
if (limitMode === "liquidity_tolerance_bps") {
|
|
@@ -434,8 +736,16 @@ function thorchainProvider(config = {}) {
|
|
|
434
736
|
params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
|
|
435
737
|
return null;
|
|
436
738
|
}
|
|
437
|
-
|
|
739
|
+
const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
|
|
740
|
+
const snapshot = await nodeSnapshot(host, signal);
|
|
741
|
+
if ("heightError" in snapshot) {
|
|
742
|
+
heightUnavailable = true;
|
|
743
|
+
throw snapshot.heightError;
|
|
744
|
+
}
|
|
745
|
+
inbounds = snapshot.inbounds;
|
|
746
|
+
quoteHeight = snapshot.height;
|
|
438
747
|
quoteHost = host;
|
|
748
|
+
confirmDeadlineAt = deadlineAt;
|
|
439
749
|
break;
|
|
440
750
|
} catch (error) {
|
|
441
751
|
if (error instanceof HostUnavailable) {
|
|
@@ -448,7 +758,9 @@ function thorchainProvider(config = {}) {
|
|
|
448
758
|
}
|
|
449
759
|
}
|
|
450
760
|
if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
|
|
451
|
-
params.onRefusal?.(
|
|
761
|
+
params.onRefusal?.(
|
|
762
|
+
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"
|
|
763
|
+
);
|
|
452
764
|
return null;
|
|
453
765
|
}
|
|
454
766
|
const limit1e8 = checkedMemo.limit;
|
|
@@ -470,7 +782,16 @@ function thorchainProvider(config = {}) {
|
|
|
470
782
|
params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
|
|
471
783
|
return null;
|
|
472
784
|
}
|
|
473
|
-
const unconfirmed = await confirmedElsewhere(
|
|
785
|
+
const unconfirmed = await confirmedElsewhere(
|
|
786
|
+
quoteHost,
|
|
787
|
+
quoteHeight,
|
|
788
|
+
params.sourceChain,
|
|
789
|
+
params.destChain,
|
|
790
|
+
q.inbound_address,
|
|
791
|
+
chainId ? q.router : void 0,
|
|
792
|
+
confirmDeadlineAt,
|
|
793
|
+
signal
|
|
794
|
+
);
|
|
474
795
|
if (unconfirmed) {
|
|
475
796
|
params.onRefusal?.(`thorchain: ${unconfirmed}`);
|
|
476
797
|
return null;
|
|
@@ -585,18 +906,42 @@ function thorchainProvider(config = {}) {
|
|
|
585
906
|
if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
|
|
586
907
|
let inbounds = null;
|
|
587
908
|
let snapshotHost = "";
|
|
909
|
+
let snapshotHeight = 0;
|
|
910
|
+
let confirmDeadlineAt = 0;
|
|
911
|
+
let heightUnavailable = false;
|
|
588
912
|
for (const host of hosts) {
|
|
913
|
+
const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
|
|
589
914
|
try {
|
|
590
|
-
|
|
915
|
+
const snapshot = await nodeSnapshot(host);
|
|
916
|
+
if ("heightError" in snapshot) {
|
|
917
|
+
heightUnavailable = true;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
inbounds = snapshot.inbounds;
|
|
921
|
+
snapshotHeight = snapshot.height;
|
|
591
922
|
snapshotHost = host;
|
|
923
|
+
confirmDeadlineAt = deadlineAt;
|
|
592
924
|
break;
|
|
593
925
|
} catch (error) {
|
|
926
|
+
inbounds = null;
|
|
594
927
|
if (!(error instanceof HostUnavailable)) throw error;
|
|
595
928
|
}
|
|
596
929
|
}
|
|
597
|
-
if (!inbounds)
|
|
930
|
+
if (!inbounds) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
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"
|
|
933
|
+
);
|
|
934
|
+
}
|
|
598
935
|
const confirm = async () => {
|
|
599
|
-
const unconfirmed = await confirmedElsewhere(
|
|
936
|
+
const unconfirmed = await confirmedElsewhere(
|
|
937
|
+
snapshotHost,
|
|
938
|
+
snapshotHeight,
|
|
939
|
+
data.sourceChain,
|
|
940
|
+
data.destChain,
|
|
941
|
+
data.inboundAddress,
|
|
942
|
+
evmSource ? data.router : void 0,
|
|
943
|
+
confirmDeadlineAt
|
|
944
|
+
);
|
|
600
945
|
if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
|
|
601
946
|
};
|
|
602
947
|
const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
|
|
@@ -688,8 +1033,9 @@ function thorchainProvider(config = {}) {
|
|
|
688
1033
|
}
|
|
689
1034
|
|
|
690
1035
|
export {
|
|
1036
|
+
THORNODE_CONFIRM_PATHS,
|
|
691
1037
|
THORCHAIN_ROUTERS,
|
|
692
1038
|
thorchainPins,
|
|
693
1039
|
thorchainProvider
|
|
694
1040
|
};
|
|
695
|
-
//# sourceMappingURL=chunk-
|
|
1041
|
+
//# sourceMappingURL=chunk-AYP7O2NT.js.map
|