@ember-finance/sdk 2.15.0 → 2.16.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/dist/src/placements/pointers.js +129 -45
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { environmentConfig } from "../common/constants.js";
|
|
2
|
-
import {
|
|
2
|
+
import { isAbortError, isTransientStatus, retryAfterMs } from "./batch.js";
|
|
3
|
+
import { coinTypesMatch, normalizeCoinType } from "./sui.js";
|
|
3
4
|
/**
|
|
4
5
|
* Pointers held in memory. Useful for tests, and for pinning a known venue set
|
|
5
6
|
* without a round-trip.
|
|
@@ -35,69 +36,152 @@ export function httpPointerSource(options = {}) {
|
|
|
35
36
|
const path = options.path ?? "/api/v2/vaults/placements";
|
|
36
37
|
const cacheMs = options.cacheMs ?? 30000;
|
|
37
38
|
/**
|
|
38
|
-
* One request
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* fetched the same list five times and threw four of them away. Reading one
|
|
42
|
-
* vault meant eight requests where two would do, and a caller sweeping every
|
|
43
|
-
* vault made hundreds — enough to be rate-limited, and a rejected discover
|
|
44
|
-
* drops that venue from the answer.
|
|
39
|
+
* One request for every coin. The endpoint answers with the whole registry
|
|
40
|
+
* when `coin` is omitted, so reading every vault costs one call rather than
|
|
41
|
+
* one per receipt coin.
|
|
45
42
|
*
|
|
46
43
|
* In-flight requests are shared rather than merely cached, so the adapters
|
|
47
44
|
* racing each other at the start of a `forAccount` collapse into one call.
|
|
48
45
|
*/
|
|
49
46
|
const inFlight = new Map();
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
// Set when the server rejects the unfiltered call, i.e. it predates it.
|
|
48
|
+
// Rechecked after a while, so a long-lived source picks up a server that
|
|
49
|
+
// has since been upgraded.
|
|
50
|
+
let perCoinUntil = 0;
|
|
51
|
+
const shared = (key, load) => {
|
|
52
52
|
const hit = inFlight.get(key);
|
|
53
53
|
if (hit && Date.now() - hit.at < cacheMs)
|
|
54
54
|
return hit.rows;
|
|
55
|
-
const rows = (
|
|
56
|
-
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
|
|
57
|
-
url.searchParams.set("coin", coin.coin);
|
|
58
|
-
const response = await fetch(url.toString(), {
|
|
59
|
-
headers: { Accept: "application/json" }
|
|
60
|
-
});
|
|
61
|
-
if (!response.ok) {
|
|
62
|
-
throw new Error(`pointer source ${response.status}`);
|
|
63
|
-
}
|
|
64
|
-
const body = (await response.json());
|
|
65
|
-
// The chain type comes from the coin, not the wire: an adapter only
|
|
66
|
-
// ever runs on its own chain, so a server that named it again could
|
|
67
|
-
// only ever disagree.
|
|
68
|
-
return (body.placements ?? []).map(placement => ({
|
|
69
|
-
id: placement.id,
|
|
70
|
-
protocol: placement.protocol,
|
|
71
|
-
chain: {
|
|
72
|
-
type: coin.chain.type,
|
|
73
|
-
chainId: placement.chainId,
|
|
74
|
-
identifier: placement.chainIdentifier
|
|
75
|
-
},
|
|
76
|
-
venue: placement.venue,
|
|
77
|
-
coin: placement.coin,
|
|
78
|
-
decimals: requireDecimals(placement),
|
|
79
|
-
protocolName: placement.protocolName,
|
|
80
|
-
protocolLogoUrl: placement.protocolLogoUrl,
|
|
81
|
-
source: placement.source,
|
|
82
|
-
label: placement.label,
|
|
83
|
-
shareToken: placement.shareToken,
|
|
84
|
-
url: placement.url,
|
|
85
|
-
rpcUrl: placement.rpcUrl
|
|
86
|
-
}));
|
|
87
|
-
})();
|
|
55
|
+
const rows = load();
|
|
88
56
|
// A failure is not cached: the next adapter should retry rather than
|
|
89
57
|
// inherit a rejection it never caused.
|
|
90
58
|
rows.catch(() => inFlight.delete(key));
|
|
91
59
|
inFlight.set(key, { at: Date.now(), rows });
|
|
92
60
|
return rows;
|
|
93
61
|
};
|
|
62
|
+
const request = (coin) => {
|
|
63
|
+
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
|
|
64
|
+
if (coin !== undefined)
|
|
65
|
+
url.searchParams.set("coin", coin);
|
|
66
|
+
return fetchPlacements(url.toString());
|
|
67
|
+
};
|
|
68
|
+
const rowsFor = async (coin) => {
|
|
69
|
+
if (Date.now() >= perCoinUntil) {
|
|
70
|
+
try {
|
|
71
|
+
return await shared("*", () => request());
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!(error instanceof PointerSourceError) || error.status !== 400) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
perCoinUntil = Date.now() + FALLBACK_RECHECK_MS;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return shared(coin.coin.toLowerCase(), () => request(coin.coin));
|
|
81
|
+
};
|
|
94
82
|
return {
|
|
95
83
|
async placements(protocol, coin) {
|
|
96
|
-
const rows = await
|
|
97
|
-
|
|
84
|
+
const rows = await rowsFor(coin);
|
|
85
|
+
const wanted = coinKey(coin.coin);
|
|
86
|
+
// One venue stored under two spellings of the coin would otherwise
|
|
87
|
+
// be read twice and double-count the position.
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
return (rows
|
|
90
|
+
.filter(row => {
|
|
91
|
+
if (row.protocol !== protocol || coinKey(row.coin) !== wanted) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const key = [row.chainIdentifier, row.venue, row.shareToken ?? ""]
|
|
95
|
+
.join(":")
|
|
96
|
+
.toLowerCase();
|
|
97
|
+
if (seen.has(key))
|
|
98
|
+
return false;
|
|
99
|
+
seen.add(key);
|
|
100
|
+
return true;
|
|
101
|
+
})
|
|
102
|
+
// The chain type comes from the coin, not the wire: an adapter
|
|
103
|
+
// only ever runs on its own chain, so a server that named it
|
|
104
|
+
// again could only ever disagree.
|
|
105
|
+
.map(row => ({
|
|
106
|
+
id: row.id,
|
|
107
|
+
protocol: row.protocol,
|
|
108
|
+
chain: {
|
|
109
|
+
type: coin.chain.type,
|
|
110
|
+
chainId: row.chainId,
|
|
111
|
+
identifier: row.chainIdentifier
|
|
112
|
+
},
|
|
113
|
+
venue: row.venue,
|
|
114
|
+
coin: row.coin,
|
|
115
|
+
decimals: requireDecimals(row),
|
|
116
|
+
protocolName: row.protocolName,
|
|
117
|
+
protocolLogoUrl: row.protocolLogoUrl,
|
|
118
|
+
source: row.source,
|
|
119
|
+
label: row.label,
|
|
120
|
+
shareToken: row.shareToken,
|
|
121
|
+
url: row.url,
|
|
122
|
+
rpcUrl: row.rpcUrl
|
|
123
|
+
})));
|
|
98
124
|
}
|
|
99
125
|
};
|
|
100
126
|
}
|
|
127
|
+
class PointerSourceError extends Error {
|
|
128
|
+
constructor(status) {
|
|
129
|
+
super(`pointer source ${status}`);
|
|
130
|
+
this.status = status;
|
|
131
|
+
this.name = "PointerSourceError";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const FETCH_ATTEMPTS = 3;
|
|
135
|
+
const RETRY_BASE_MS = 250;
|
|
136
|
+
const RETRY_MAX_MS = 2000;
|
|
137
|
+
const FALLBACK_RECHECK_MS = 5 * 60000;
|
|
138
|
+
/**
|
|
139
|
+
* Retries transient failures. Every vault's venues now ride on this one
|
|
140
|
+
* request, and callers keep a failed discover rather than retrying it.
|
|
141
|
+
*/
|
|
142
|
+
async function fetchPlacements(url) {
|
|
143
|
+
for (let attempt = 1;; attempt++) {
|
|
144
|
+
let waitMs;
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetch(url, {
|
|
147
|
+
headers: { Accept: "application/json" }
|
|
148
|
+
});
|
|
149
|
+
if (response.ok) {
|
|
150
|
+
const body = (await response.json());
|
|
151
|
+
return body.placements ?? [];
|
|
152
|
+
}
|
|
153
|
+
if (!isTransientStatus(response.status) || attempt >= FETCH_ATTEMPTS) {
|
|
154
|
+
throw new PointerSourceError(response.status);
|
|
155
|
+
}
|
|
156
|
+
waitMs = retryAfterMs(response.headers?.get("retry-after"));
|
|
157
|
+
// Retrying before the server asked would only add to the load it
|
|
158
|
+
// is shedding and fail again.
|
|
159
|
+
if (waitMs !== undefined && waitMs > RETRY_MAX_MS) {
|
|
160
|
+
throw new PointerSourceError(response.status);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// fetch rejects only on network failure; retry that, not our own
|
|
165
|
+
// errors or an abort.
|
|
166
|
+
if (error instanceof PointerSourceError ||
|
|
167
|
+
isAbortError(error) ||
|
|
168
|
+
attempt >= FETCH_ATTEMPTS) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_MS);
|
|
173
|
+
await new Promise(resolve => setTimeout(resolve, waitMs ?? backoff));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Coin identity across spellings. `coinTypesMatch` folds a Sui address; EVM
|
|
178
|
+
* addresses are compared case-insensitively and with or without `0x`, since
|
|
179
|
+
* the API returns them lowercased and the vaults list checksums them.
|
|
180
|
+
*/
|
|
181
|
+
function coinKey(coin) {
|
|
182
|
+
const normalized = normalizeCoinType(coin);
|
|
183
|
+
return normalized.includes("::") ? normalized : normalized.replace(/^0x/, "");
|
|
184
|
+
}
|
|
101
185
|
/**
|
|
102
186
|
* Merges what every source returns, de-duplicated by venue. Earlier sources win,
|
|
103
187
|
* so a caller can pin rows ahead of a remote set. A source that throws is
|