@ember-finance/sdk 2.11.0-pre.5 → 2.11.0-pre.7
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/batch.d.ts +9 -0
- package/dist/src/placements/batch.js +33 -2
- package/dist/src/placements/client.js +11 -2
- package/dist/src/placements/evm.d.ts +3 -1
- package/dist/src/placements/evm.js +83 -33
- package/dist/src/placements/index.d.ts +1 -1
- package/dist/src/placements/index.js +1 -1
- package/dist/src/placements/sui.d.ts +3 -0
- package/dist/src/placements/sui.js +55 -21
- package/dist/src/placements/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -17,8 +17,12 @@ export declare class TransientError extends Error {
|
|
|
17
17
|
export declare class ChunkTooLargeError extends Error {
|
|
18
18
|
constructor(message: string);
|
|
19
19
|
}
|
|
20
|
+
export declare class AbortedError extends Error {
|
|
21
|
+
constructor(message?: string);
|
|
22
|
+
}
|
|
20
23
|
export declare function retryAfterMs(header: string | null | undefined): number | undefined;
|
|
21
24
|
export declare function isTransientStatus(status: number): boolean;
|
|
25
|
+
export declare function isAbortError(error: unknown): boolean;
|
|
22
26
|
export interface BatcherOptions<Item, Result> {
|
|
23
27
|
send: (items: Item[], attempt: number) => Promise<BatchResult<Result>[]>;
|
|
24
28
|
key: (item: Item) => string;
|
|
@@ -29,8 +33,10 @@ export interface BatcherOptions<Item, Result> {
|
|
|
29
33
|
concurrency?: number;
|
|
30
34
|
retries?: number;
|
|
31
35
|
baseDelayMs?: number;
|
|
36
|
+
maxDelayMs?: number;
|
|
32
37
|
sleep?: (ms: number) => Promise<void>;
|
|
33
38
|
schedule?: (flush: () => void) => void;
|
|
39
|
+
signal?: AbortSignal;
|
|
34
40
|
}
|
|
35
41
|
export declare class Batcher<Item, Result> {
|
|
36
42
|
private readonly send;
|
|
@@ -42,8 +48,10 @@ export declare class Batcher<Item, Result> {
|
|
|
42
48
|
private readonly concurrency;
|
|
43
49
|
private readonly retries;
|
|
44
50
|
private readonly baseDelayMs;
|
|
51
|
+
private readonly maxDelayMs;
|
|
45
52
|
private readonly sleep;
|
|
46
53
|
private readonly schedule;
|
|
54
|
+
private readonly signal?;
|
|
47
55
|
private readonly queue;
|
|
48
56
|
private readonly inFlight;
|
|
49
57
|
private readonly waiting;
|
|
@@ -55,6 +63,7 @@ export declare class Batcher<Item, Result> {
|
|
|
55
63
|
private flush;
|
|
56
64
|
private chunk;
|
|
57
65
|
private run;
|
|
66
|
+
private retryLater;
|
|
58
67
|
private backoff;
|
|
59
68
|
private acquire;
|
|
60
69
|
private release;
|
|
@@ -11,6 +11,12 @@ export class ChunkTooLargeError extends Error {
|
|
|
11
11
|
this.name = "ChunkTooLargeError";
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
export class AbortedError extends Error {
|
|
15
|
+
constructor(message = "aborted") {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "AbortedError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
14
20
|
export function retryAfterMs(header) {
|
|
15
21
|
if (!header)
|
|
16
22
|
return undefined;
|
|
@@ -23,6 +29,10 @@ export function retryAfterMs(header) {
|
|
|
23
29
|
export function isTransientStatus(status) {
|
|
24
30
|
return status === 429 || status >= 500;
|
|
25
31
|
}
|
|
32
|
+
export function isAbortError(error) {
|
|
33
|
+
return (error instanceof AbortedError ||
|
|
34
|
+
(error instanceof Error && error.name === "AbortError"));
|
|
35
|
+
}
|
|
26
36
|
const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
27
37
|
const defaultSchedule = (flush) => {
|
|
28
38
|
setTimeout(flush, 0);
|
|
@@ -43,10 +53,14 @@ export class Batcher {
|
|
|
43
53
|
this.concurrency = options.concurrency ?? 3;
|
|
44
54
|
this.retries = options.retries ?? 3;
|
|
45
55
|
this.baseDelayMs = options.baseDelayMs ?? 300;
|
|
56
|
+
this.maxDelayMs = options.maxDelayMs ?? 10000;
|
|
46
57
|
this.sleep = options.sleep ?? defaultSleep;
|
|
47
58
|
this.schedule = options.schedule ?? defaultSchedule;
|
|
59
|
+
this.signal = options.signal;
|
|
48
60
|
}
|
|
49
61
|
enqueue(item) {
|
|
62
|
+
if (this.signal?.aborted)
|
|
63
|
+
return Promise.reject(new AbortedError());
|
|
50
64
|
const key = this.key(item);
|
|
51
65
|
const shared = this.inFlight.get(key);
|
|
52
66
|
if (shared)
|
|
@@ -103,6 +117,11 @@ export class Batcher {
|
|
|
103
117
|
return chunks;
|
|
104
118
|
}
|
|
105
119
|
async run(chunk, attempt) {
|
|
120
|
+
if (this.signal?.aborted) {
|
|
121
|
+
for (const entry of chunk)
|
|
122
|
+
entry.reject(new AbortedError());
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
106
125
|
await this.acquire();
|
|
107
126
|
let results;
|
|
108
127
|
try {
|
|
@@ -117,8 +136,7 @@ export class Batcher {
|
|
|
117
136
|
return;
|
|
118
137
|
}
|
|
119
138
|
if (error instanceof TransientError && attempt <= this.retries) {
|
|
120
|
-
await this.
|
|
121
|
-
void this.run(chunk, attempt + 1);
|
|
139
|
+
await this.retryLater(chunk, attempt, error.retryAfterMs);
|
|
122
140
|
return;
|
|
123
141
|
}
|
|
124
142
|
const failure = error instanceof Error ? error : new Error(String(error));
|
|
@@ -127,6 +145,8 @@ export class Batcher {
|
|
|
127
145
|
return;
|
|
128
146
|
}
|
|
129
147
|
this.release();
|
|
148
|
+
const throttled = [];
|
|
149
|
+
let retryAfter;
|
|
130
150
|
chunk.forEach((entry, index) => {
|
|
131
151
|
const result = results[index];
|
|
132
152
|
if (result === undefined) {
|
|
@@ -137,8 +157,19 @@ export class Batcher {
|
|
|
137
157
|
entry.resolve(result.value);
|
|
138
158
|
return;
|
|
139
159
|
}
|
|
160
|
+
if (result.error instanceof TransientError && attempt <= this.retries) {
|
|
161
|
+
throttled.push(entry);
|
|
162
|
+
retryAfter = retryAfter ?? result.error.retryAfterMs;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
140
165
|
entry.reject(result.error);
|
|
141
166
|
});
|
|
167
|
+
if (throttled.length > 0)
|
|
168
|
+
await this.retryLater(throttled, attempt, retryAfter);
|
|
169
|
+
}
|
|
170
|
+
async retryLater(chunk, attempt, retryAfter) {
|
|
171
|
+
await this.sleep(Math.min(retryAfter ?? this.backoff(attempt), this.maxDelayMs));
|
|
172
|
+
void this.run(chunk, attempt + 1);
|
|
142
173
|
}
|
|
143
174
|
backoff(attempt) {
|
|
144
175
|
const base = this.baseDelayMs * 2 ** (attempt - 1);
|
|
@@ -31,7 +31,8 @@ function unresolvedVault(vaultId, reason) {
|
|
|
31
31
|
available: "0",
|
|
32
32
|
covered: undefined,
|
|
33
33
|
placements: [],
|
|
34
|
-
failures: [{ protocol: "vault", stage: "discover", reason }]
|
|
34
|
+
failures: [{ protocol: "vault", stage: "discover", reason }],
|
|
35
|
+
unresolved: true
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
export class PlacementsClient {
|
|
@@ -51,7 +52,15 @@ export class PlacementsClient {
|
|
|
51
52
|
const vaults = this.ctx.vaults;
|
|
52
53
|
const resolved = await Promise.all(vaultIds.map(async (vaultId) => {
|
|
53
54
|
try {
|
|
54
|
-
|
|
55
|
+
const coins = await vaults.coinsForVault(vaultId);
|
|
56
|
+
if (!Array.isArray(coins) || coins.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
vaultId,
|
|
59
|
+
coins: undefined,
|
|
60
|
+
reason: "resolver returned no coins"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { vaultId, coins };
|
|
55
64
|
}
|
|
56
65
|
catch (error) {
|
|
57
66
|
return { vaultId, coins: undefined, reason: reasonOf(error) };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EvmReader } from "./types.js";
|
|
2
2
|
import { TransportRequest } from "./batch.js";
|
|
3
|
+
export type { TransportRequest } from "./batch.js";
|
|
3
4
|
export declare function encodeBalanceOf(owner: string): string;
|
|
4
5
|
export declare function asContractAddress(address: string): string;
|
|
5
6
|
export declare function decodeUint256(hex: string): bigint;
|
|
@@ -8,8 +9,10 @@ export interface JsonRpcEvmReaderOptions {
|
|
|
8
9
|
concurrency?: number;
|
|
9
10
|
retries?: number;
|
|
10
11
|
baseDelayMs?: number;
|
|
12
|
+
maxDelayMs?: number;
|
|
11
13
|
sleep?: (ms: number) => Promise<void>;
|
|
12
14
|
fetch?: typeof globalThis.fetch;
|
|
15
|
+
signal?: AbortSignal;
|
|
13
16
|
onRequest?: (request: TransportRequest) => void;
|
|
14
17
|
multicall?: boolean;
|
|
15
18
|
}
|
|
@@ -24,4 +27,3 @@ export declare function decodeAggregate3(data: string, count: number): {
|
|
|
24
27
|
data: string;
|
|
25
28
|
}[];
|
|
26
29
|
export declare function jsonRpcEvmReader(endpoints?: Record<number, string>, options?: JsonRpcEvmReaderOptions): EvmReader;
|
|
27
|
-
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Batcher, ChunkTooLargeError, TransientError, isTransientStatus, retryAfterMs } from "./batch.js";
|
|
1
|
+
import { Batcher, ChunkTooLargeError, TransientError, isAbortError, isTransientStatus, retryAfterMs } from "./batch.js";
|
|
2
2
|
/** `balanceOf(address)` */
|
|
3
3
|
const BALANCE_OF_SELECTOR = "0x70a08231";
|
|
4
4
|
export function encodeBalanceOf(owner) {
|
|
@@ -19,6 +19,14 @@ export function decodeUint256(hex) {
|
|
|
19
19
|
export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
20
20
|
const AGGREGATE3_SELECTOR = "0x82ad56cb";
|
|
21
21
|
const RATE_LIMITED = /too many requests|rate ?limit/i;
|
|
22
|
+
const HEX = /^(0x)?[0-9a-fA-F]*$/;
|
|
23
|
+
class HttpError extends Error {
|
|
24
|
+
constructor(status) {
|
|
25
|
+
super(`evm rpc ${status}`);
|
|
26
|
+
this.name = "HttpError";
|
|
27
|
+
this.status = status;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
22
30
|
function stripHex(value) {
|
|
23
31
|
return value.trim().replace(/^0x/i, "");
|
|
24
32
|
}
|
|
@@ -90,27 +98,47 @@ function isRateLimited(entry) {
|
|
|
90
98
|
function toResult(entry) {
|
|
91
99
|
if (!entry)
|
|
92
100
|
return { ok: false, error: new Error("evm rpc: no result for call") };
|
|
101
|
+
if (isRateLimited(entry)) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: new TransientError(`evm rpc: ${entry.error?.message}`)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
93
107
|
if (entry.error)
|
|
94
108
|
return { ok: false, error: new Error(`evm rpc: ${entry.error.message}`) };
|
|
95
109
|
return { ok: true, value: entry.result ?? "0x" };
|
|
96
110
|
}
|
|
111
|
+
function validateCall(to, data) {
|
|
112
|
+
const address = stripHex(to);
|
|
113
|
+
if (!HEX.test(to) || address.length !== 40) {
|
|
114
|
+
throw new Error(`evm rpc: ${to} is not a contract address`);
|
|
115
|
+
}
|
|
116
|
+
const calldata = stripHex(data);
|
|
117
|
+
if (!HEX.test(data) || calldata.length % 2 !== 0) {
|
|
118
|
+
throw new Error(`evm rpc: calldata ${data} is not even-length hex`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
97
121
|
export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
98
122
|
const fetchImpl = options.fetch ??
|
|
99
123
|
((input, init) => globalThis.fetch(input, init));
|
|
100
124
|
const onRequest = options.onRequest;
|
|
101
|
-
const useMulticall = options.multicall ??
|
|
125
|
+
const useMulticall = options.multicall ?? false;
|
|
102
126
|
const batchers = new Map();
|
|
103
127
|
const modes = new Map();
|
|
128
|
+
const probes = new Map();
|
|
104
129
|
const post = async (endpoint, body, items, attempt) => {
|
|
105
130
|
let response;
|
|
106
131
|
try {
|
|
107
132
|
response = await fetchImpl(endpoint, {
|
|
108
133
|
method: "POST",
|
|
109
134
|
headers: { "Content-Type": "application/json" },
|
|
110
|
-
body: JSON.stringify(body)
|
|
135
|
+
body: JSON.stringify(body),
|
|
136
|
+
signal: options.signal
|
|
111
137
|
});
|
|
112
138
|
}
|
|
113
139
|
catch (error) {
|
|
140
|
+
if (isAbortError(error))
|
|
141
|
+
throw error;
|
|
114
142
|
onRequest?.({ items, attempt, status: "network-error" });
|
|
115
143
|
throw new TransientError(`evm rpc: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
144
|
}
|
|
@@ -118,39 +146,58 @@ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
|
118
146
|
if (isTransientStatus(response.status)) {
|
|
119
147
|
throw new TransientError(`evm rpc ${response.status}`, retryAfterMs(response.headers?.get("retry-after")));
|
|
120
148
|
}
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
throw new HttpError(response.status);
|
|
121
151
|
return response;
|
|
122
152
|
};
|
|
123
153
|
const rpc = async (endpoint, method, params, items, attempt) => {
|
|
124
154
|
const response = await post(endpoint, jsonRpcRequest(method, params, 1), items, attempt);
|
|
125
|
-
if (!response.ok)
|
|
126
|
-
throw new Error(`evm rpc ${response.status}`);
|
|
127
155
|
const parsed = (await response.json());
|
|
128
156
|
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
129
157
|
if (isRateLimited(entry))
|
|
130
158
|
throw new TransientError(`evm rpc: ${entry?.error?.message}`);
|
|
131
159
|
return entry ?? {};
|
|
132
160
|
};
|
|
133
|
-
const detectMode =
|
|
161
|
+
const detectMode = (endpoint, attempt) => {
|
|
134
162
|
const known = modes.get(endpoint);
|
|
135
163
|
if (known)
|
|
136
|
-
return known;
|
|
137
|
-
|
|
138
|
-
|
|
164
|
+
return Promise.resolve(known);
|
|
165
|
+
if (!useMulticall) {
|
|
166
|
+
modes.set(endpoint, "batch");
|
|
167
|
+
return Promise.resolve("batch");
|
|
168
|
+
}
|
|
169
|
+
const pending = probes.get(endpoint);
|
|
170
|
+
if (pending)
|
|
171
|
+
return pending;
|
|
172
|
+
const probe = (async () => {
|
|
173
|
+
let mode = "batch";
|
|
139
174
|
try {
|
|
140
175
|
const code = await rpc(endpoint, "eth_getCode", [MULTICALL3_ADDRESS, "latest"], 1, attempt);
|
|
141
176
|
if (!code.error && stripHex(code.result ?? "").length > 2)
|
|
142
177
|
mode = "multicall";
|
|
143
178
|
}
|
|
144
179
|
catch (error) {
|
|
145
|
-
if (error instanceof TransientError)
|
|
180
|
+
if (error instanceof TransientError || isAbortError(error))
|
|
146
181
|
throw error;
|
|
147
182
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
183
|
+
modes.set(endpoint, mode);
|
|
184
|
+
return mode;
|
|
185
|
+
})();
|
|
186
|
+
probes.set(endpoint, probe);
|
|
187
|
+
const clear = () => probes.delete(endpoint);
|
|
188
|
+
probe.then(clear, clear);
|
|
189
|
+
return probe;
|
|
151
190
|
};
|
|
152
191
|
const sendMulticall = async (endpoint, calls, attempt) => {
|
|
153
|
-
|
|
192
|
+
let entry;
|
|
193
|
+
try {
|
|
194
|
+
entry = await rpc(endpoint, "eth_call", [{ to: MULTICALL3_ADDRESS, data: encodeAggregate3(calls) }, "latest"], calls.length, attempt);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof TransientError || isAbortError(error))
|
|
198
|
+
throw error;
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
154
201
|
if (entry.error || !entry.result)
|
|
155
202
|
return undefined;
|
|
156
203
|
let decoded;
|
|
@@ -161,37 +208,37 @@ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
|
161
208
|
return undefined;
|
|
162
209
|
}
|
|
163
210
|
return decoded.map(result => result.success
|
|
164
|
-
? { ok: true, value: result.data
|
|
211
|
+
? { ok: true, value: result.data }
|
|
165
212
|
: { ok: false, error: new Error("evm rpc: execution reverted") });
|
|
166
213
|
};
|
|
167
214
|
const sendBatch = async (endpoint, calls, attempt) => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
215
|
+
let response;
|
|
216
|
+
try {
|
|
217
|
+
response = await post(endpoint, calls.map((call, id) => callRequest(call, id)), calls.length, attempt);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error instanceof HttpError) {
|
|
221
|
+
modes.set(endpoint, "single");
|
|
222
|
+
throw new ChunkTooLargeError(error.message);
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
172
225
|
}
|
|
173
226
|
const parsed = (await response.json());
|
|
174
227
|
if (!Array.isArray(parsed)) {
|
|
175
228
|
modes.set(endpoint, "single");
|
|
176
229
|
throw new ChunkTooLargeError("evm rpc: batch unsupported");
|
|
177
230
|
}
|
|
178
|
-
const limited = parsed.find(isRateLimited);
|
|
179
|
-
if (limited)
|
|
180
|
-
throw new TransientError(`evm rpc: ${limited.error?.message}`);
|
|
181
231
|
const byId = new Map(parsed.map(entry => [Number(entry.id), entry]));
|
|
182
232
|
return calls.map((_, id) => toResult(byId.get(id)));
|
|
183
233
|
};
|
|
234
|
+
const sendSingle = async (endpoint, call, attempt) => {
|
|
235
|
+
const response = await post(endpoint, callRequest(call, 1), 1, attempt);
|
|
236
|
+
const parsed = (await response.json());
|
|
237
|
+
return [toResult(Array.isArray(parsed) ? parsed[0] : parsed)];
|
|
238
|
+
};
|
|
184
239
|
const send = async (endpoint, calls, attempt) => {
|
|
185
|
-
if (calls.length === 1)
|
|
186
|
-
|
|
187
|
-
if (!response.ok)
|
|
188
|
-
throw new Error(`evm rpc ${response.status}`);
|
|
189
|
-
const parsed = (await response.json());
|
|
190
|
-
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
191
|
-
if (isRateLimited(entry))
|
|
192
|
-
throw new TransientError(`evm rpc: ${entry?.error?.message}`);
|
|
193
|
-
return [toResult(entry)];
|
|
194
|
-
}
|
|
240
|
+
if (calls.length === 1)
|
|
241
|
+
return sendSingle(endpoint, calls[0], attempt);
|
|
195
242
|
const mode = await detectMode(endpoint, attempt);
|
|
196
243
|
if (mode === "single")
|
|
197
244
|
throw new ChunkTooLargeError("evm rpc: batch unsupported");
|
|
@@ -214,7 +261,9 @@ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
|
214
261
|
concurrency: options.concurrency,
|
|
215
262
|
retries: options.retries,
|
|
216
263
|
baseDelayMs: options.baseDelayMs,
|
|
217
|
-
|
|
264
|
+
maxDelayMs: options.maxDelayMs,
|
|
265
|
+
sleep: options.sleep,
|
|
266
|
+
signal: options.signal
|
|
218
267
|
});
|
|
219
268
|
batchers.set(endpoint, batcher);
|
|
220
269
|
return batcher;
|
|
@@ -224,6 +273,7 @@ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
|
224
273
|
const endpoint = override ?? endpoints[chainId];
|
|
225
274
|
if (!endpoint)
|
|
226
275
|
throw new Error(`no evm endpoint for chain ${chainId}`);
|
|
276
|
+
validateCall(to, data);
|
|
227
277
|
return batcherFor(endpoint).enqueue({ to, data });
|
|
228
278
|
}
|
|
229
279
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SuiReader } from "./types.js";
|
|
2
2
|
import { TransportRequest } from "./batch.js";
|
|
3
|
+
export type { TransportRequest } from "./batch.js";
|
|
3
4
|
/**
|
|
4
5
|
* Canonical form for a Sui coin type: `0x` stripped from the address segment,
|
|
5
6
|
* lowercased and left-padded to 64 hex chars.
|
|
@@ -37,8 +38,10 @@ export interface GraphqlSuiReaderOptions {
|
|
|
37
38
|
concurrency?: number;
|
|
38
39
|
retries?: number;
|
|
39
40
|
baseDelayMs?: number;
|
|
41
|
+
maxDelayMs?: number;
|
|
40
42
|
sleep?: (ms: number) => Promise<void>;
|
|
41
43
|
fetch?: typeof globalThis.fetch;
|
|
44
|
+
signal?: AbortSignal;
|
|
42
45
|
onRequest?: (request: TransportRequest) => void;
|
|
43
46
|
}
|
|
44
47
|
export declare function graphqlSuiReader(options?: string | GraphqlSuiReaderOptions): SuiReader;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Batcher, ChunkTooLargeError, TransientError, isTransientStatus, retryAfterMs } from "./batch.js";
|
|
1
|
+
import { Batcher, ChunkTooLargeError, TransientError, isAbortError, isTransientStatus, retryAfterMs } from "./batch.js";
|
|
2
2
|
const GRAPHQL_PAGE_SIZE = 50;
|
|
3
3
|
/**
|
|
4
4
|
* Canonical form for a Sui coin type: `0x` stripped from the address segment,
|
|
@@ -90,14 +90,21 @@ const OBJECTS_ALIAS = "objects";
|
|
|
90
90
|
const OBJECT_SELECTION = "{ address asMoveObject { contents { type { repr } json } } }";
|
|
91
91
|
const OBJECT_KEY_WEIGHT = 0.25;
|
|
92
92
|
const OBJECT_KEY_BYTES = 90;
|
|
93
|
+
const SUI_ADDRESS = /^(0x)?[0-9a-fA-F]{1,64}$/;
|
|
93
94
|
function normalizeAddress(address) {
|
|
94
95
|
return address.trim().toLowerCase().replace(/^0x/, "").padStart(64, "0");
|
|
95
96
|
}
|
|
97
|
+
function isSuiAddress(value) {
|
|
98
|
+
return SUI_ADDRESS.test(value.trim());
|
|
99
|
+
}
|
|
100
|
+
function literal(value) {
|
|
101
|
+
return JSON.stringify(value);
|
|
102
|
+
}
|
|
96
103
|
function buildDocument(chunk) {
|
|
97
104
|
const roots = [];
|
|
98
105
|
const ids = chunk.flatMap(item => (item.kind === "object" ? [item.id] : []));
|
|
99
106
|
if (ids.length > 0) {
|
|
100
|
-
const keys = ids.map(id => `{ address:
|
|
107
|
+
const keys = ids.map(id => `{ address: ${literal(id)} }`).join(", ");
|
|
101
108
|
roots.push(`${OBJECTS_ALIAS}: multiGetObjects(keys: [${keys}]) ${OBJECT_SELECTION}`);
|
|
102
109
|
}
|
|
103
110
|
chunk.forEach((item, index) => {
|
|
@@ -107,19 +114,25 @@ function buildDocument(chunk) {
|
|
|
107
114
|
return `{ ${roots.join(" ")} }`;
|
|
108
115
|
}
|
|
109
116
|
function distribute(chunk, body) {
|
|
110
|
-
const
|
|
117
|
+
const aliasErrors = new Map();
|
|
118
|
+
const objectErrors = new Map();
|
|
111
119
|
const rootErrors = [];
|
|
112
120
|
for (const error of body.errors ?? []) {
|
|
113
121
|
const alias = error.path?.[0];
|
|
114
|
-
if (typeof alias
|
|
115
|
-
|
|
122
|
+
if (typeof alias !== "string") {
|
|
123
|
+
rootErrors.push(error.message);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const position = error.path?.[1];
|
|
127
|
+
if (alias === OBJECTS_ALIAS && typeof position === "number") {
|
|
128
|
+
objectErrors.set(position, error.message);
|
|
116
129
|
}
|
|
117
130
|
else {
|
|
118
|
-
|
|
131
|
+
aliasErrors.set(alias, error.message);
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
|
-
if (rootErrors.length > 0) {
|
|
122
|
-
const message = `sui graphql: ${rootErrors.join("; ")}`;
|
|
134
|
+
if (rootErrors.length > 0 || (body.data == null && (body.errors?.length ?? 0) > 0)) {
|
|
135
|
+
const message = `sui graphql: ${[...rootErrors, ...aliasErrors.values(), ...objectErrors.values()].join("; ")}`;
|
|
123
136
|
if (chunk.length > 1)
|
|
124
137
|
throw new ChunkTooLargeError(message);
|
|
125
138
|
throw new Error(message);
|
|
@@ -136,15 +149,21 @@ function distribute(chunk, body) {
|
|
|
136
149
|
json: contents.json
|
|
137
150
|
});
|
|
138
151
|
}
|
|
152
|
+
let objectPosition = -1;
|
|
139
153
|
return chunk.map((item, index) => {
|
|
140
|
-
const alias = item.kind === "object" ? OBJECTS_ALIAS : `r${index}`;
|
|
141
|
-
const message = pathErrors.get(alias);
|
|
142
|
-
if (message !== undefined) {
|
|
143
|
-
return { ok: false, error: new Error(`sui graphql: ${message}`) };
|
|
144
|
-
}
|
|
145
154
|
if (item.kind === "object") {
|
|
155
|
+
objectPosition += 1;
|
|
156
|
+
const message = objectErrors.get(objectPosition) ?? aliasErrors.get(OBJECTS_ALIAS);
|
|
157
|
+
if (message !== undefined) {
|
|
158
|
+
return { ok: false, error: new Error(`sui graphql: ${message}`) };
|
|
159
|
+
}
|
|
146
160
|
return { ok: true, value: objectsByAddress.get(normalizeAddress(item.id)) };
|
|
147
161
|
}
|
|
162
|
+
const alias = `r${index}`;
|
|
163
|
+
const message = aliasErrors.get(alias);
|
|
164
|
+
if (message !== undefined) {
|
|
165
|
+
return { ok: false, error: new Error(`sui graphql: ${message}`) };
|
|
166
|
+
}
|
|
148
167
|
return { ok: true, value: data[alias] };
|
|
149
168
|
});
|
|
150
169
|
}
|
|
@@ -161,10 +180,13 @@ export function graphqlSuiReader(options = {}) {
|
|
|
161
180
|
response = await fetchImpl(endpoint, {
|
|
162
181
|
method: "POST",
|
|
163
182
|
headers: { "Content-Type": "application/json" },
|
|
164
|
-
body: JSON.stringify({ query: document })
|
|
183
|
+
body: JSON.stringify({ query: document }),
|
|
184
|
+
signal: config.signal
|
|
165
185
|
});
|
|
166
186
|
}
|
|
167
187
|
catch (error) {
|
|
188
|
+
if (isAbortError(error))
|
|
189
|
+
throw error;
|
|
168
190
|
onRequest?.({ items: chunk.length, attempt, status: "network-error" });
|
|
169
191
|
throw new TransientError(`sui graphql: ${error instanceof Error ? error.message : String(error)}`);
|
|
170
192
|
}
|
|
@@ -194,12 +216,20 @@ export function graphqlSuiReader(options = {}) {
|
|
|
194
216
|
concurrency: config.concurrency,
|
|
195
217
|
retries: config.retries,
|
|
196
218
|
baseDelayMs: config.baseDelayMs,
|
|
197
|
-
|
|
219
|
+
maxDelayMs: config.maxDelayMs,
|
|
220
|
+
sleep: config.sleep,
|
|
221
|
+
signal: config.signal
|
|
198
222
|
});
|
|
199
223
|
const field = (gql) => batcher.enqueue({ kind: "field", field: gql });
|
|
224
|
+
const requireAddress = (value, what) => {
|
|
225
|
+
if (!isSuiAddress(value))
|
|
226
|
+
throw new Error(`sui graphql: ${what} is not a sui address`);
|
|
227
|
+
};
|
|
200
228
|
return {
|
|
201
229
|
async objects(ids) {
|
|
202
230
|
const unique = [...new Set(ids)];
|
|
231
|
+
for (const id of unique)
|
|
232
|
+
requireAddress(id, `object id ${id}`);
|
|
203
233
|
const found = await Promise.all(unique.map(id => batcher.enqueue({ kind: "object", id })));
|
|
204
234
|
const out = {};
|
|
205
235
|
for (const object of found) {
|
|
@@ -209,15 +239,17 @@ export function graphqlSuiReader(options = {}) {
|
|
|
209
239
|
return out;
|
|
210
240
|
},
|
|
211
241
|
async balance(owner, coinType) {
|
|
212
|
-
|
|
242
|
+
requireAddress(owner, `owner ${owner}`);
|
|
243
|
+
const data = await field(`address(address: ${literal(owner)}) { balance(coinType: ${literal(coinType)}) { totalBalance } }`);
|
|
213
244
|
return data?.balance?.totalBalance ?? "0";
|
|
214
245
|
},
|
|
215
246
|
async ownedObjects(owner, type) {
|
|
247
|
+
requireAddress(owner, `owner ${owner}`);
|
|
216
248
|
const owned = [];
|
|
217
249
|
let cursor = null;
|
|
218
250
|
for (;;) {
|
|
219
|
-
const after = cursor ? `, after:
|
|
220
|
-
const page = await field(`address(address:
|
|
251
|
+
const after = cursor ? `, after: ${literal(cursor)}` : "";
|
|
252
|
+
const page = await field(`address(address: ${literal(owner)}) { objects(first: ${GRAPHQL_PAGE_SIZE}${after}, filter: { type: ${literal(type)} }) { pageInfo { hasNextPage endCursor } nodes { address contents { type { repr } json } } } }`);
|
|
221
253
|
const container = page?.objects;
|
|
222
254
|
if (!container)
|
|
223
255
|
break;
|
|
@@ -237,15 +269,17 @@ export function graphqlSuiReader(options = {}) {
|
|
|
237
269
|
return owned;
|
|
238
270
|
},
|
|
239
271
|
async dynamicField(parentId, key) {
|
|
240
|
-
|
|
272
|
+
requireAddress(parentId, `parent ${parentId}`);
|
|
273
|
+
const data = await field(`address(address: ${literal(parentId)}) { dynamicField(name: { type: ${literal(key.type)}, bcs: ${literal(key.bcs)} }) { value { ... on MoveValue { json } } } }`);
|
|
241
274
|
return data?.dynamicField?.value?.json;
|
|
242
275
|
},
|
|
243
276
|
async dynamicFields(parentId, limit) {
|
|
277
|
+
requireAddress(parentId, `parent ${parentId}`);
|
|
244
278
|
const fields = [];
|
|
245
279
|
let cursor = null;
|
|
246
280
|
for (;;) {
|
|
247
|
-
const after = cursor ? `, after:
|
|
248
|
-
const page = await field(`address(address:
|
|
281
|
+
const after = cursor ? `, after: ${literal(cursor)}` : "";
|
|
282
|
+
const page = await field(`address(address: ${literal(parentId)}) { dynamicFields(first: ${GRAPHQL_PAGE_SIZE}${after}) { pageInfo { hasNextPage endCursor } nodes { name { json } value { ... on MoveValue { type { repr } json } } } } }`);
|
|
249
283
|
const container = page?.dynamicFields;
|
|
250
284
|
if (!container)
|
|
251
285
|
break;
|