@ember-finance/sdk 2.11.0-pre.4 → 2.11.0-pre.6
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 +14 -0
- package/dist/src/placements/evm.js +203 -32
- package/dist/src/placements/index.d.ts +0 -1
- package/dist/src/placements/index.js +0 -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,21 @@ 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;
|
|
17
|
+
multicall?: boolean;
|
|
14
18
|
}
|
|
19
|
+
interface EvmCall {
|
|
20
|
+
to: string;
|
|
21
|
+
data: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
24
|
+
export declare function encodeAggregate3(calls: EvmCall[]): string;
|
|
25
|
+
export declare function decodeAggregate3(data: string, count: number): {
|
|
26
|
+
success: boolean;
|
|
27
|
+
data: string;
|
|
28
|
+
}[];
|
|
15
29
|
export declare function jsonRpcEvmReader(endpoints?: Record<number, string>, options?: JsonRpcEvmReaderOptions): EvmReader;
|
|
@@ -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) {
|
|
@@ -16,72 +16,240 @@ export function decodeUint256(hex) {
|
|
|
16
16
|
return 0n;
|
|
17
17
|
return BigInt(hex);
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
20
|
+
const AGGREGATE3_SELECTOR = "0x82ad56cb";
|
|
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
|
+
}
|
|
30
|
+
function stripHex(value) {
|
|
31
|
+
return value.trim().replace(/^0x/i, "");
|
|
32
|
+
}
|
|
33
|
+
function word(value) {
|
|
34
|
+
return BigInt(value).toString(16).padStart(64, "0");
|
|
35
|
+
}
|
|
36
|
+
function addressWord(address) {
|
|
37
|
+
return stripHex(address).toLowerCase().padStart(64, "0");
|
|
38
|
+
}
|
|
39
|
+
function bytesWords(data) {
|
|
40
|
+
const raw = stripHex(data);
|
|
41
|
+
const length = raw.length / 2;
|
|
42
|
+
const padded = raw.padEnd(Math.ceil(length / 32) * 64, "0");
|
|
43
|
+
return word(length) + padded;
|
|
44
|
+
}
|
|
45
|
+
export function encodeAggregate3(calls) {
|
|
46
|
+
const tuples = calls.map(call => addressWord(call.to) + word(1) + word(0x60) + bytesWords(call.data));
|
|
47
|
+
let offset = calls.length * 32;
|
|
48
|
+
const offsets = tuples.map(tuple => {
|
|
49
|
+
const current = offset;
|
|
50
|
+
offset += tuple.length / 2;
|
|
51
|
+
return word(current);
|
|
52
|
+
});
|
|
53
|
+
return (AGGREGATE3_SELECTOR +
|
|
54
|
+
word(0x20) +
|
|
55
|
+
word(calls.length) +
|
|
56
|
+
offsets.join("") +
|
|
57
|
+
tuples.join(""));
|
|
58
|
+
}
|
|
59
|
+
export function decodeAggregate3(data, count) {
|
|
60
|
+
const raw = stripHex(data);
|
|
61
|
+
const read = (byteOffset) => {
|
|
62
|
+
const slice = raw.slice(byteOffset * 2, byteOffset * 2 + 64);
|
|
63
|
+
if (slice.length !== 64)
|
|
64
|
+
throw new Error("multicall: truncated response");
|
|
65
|
+
return Number(BigInt(`0x${slice}`));
|
|
25
66
|
};
|
|
67
|
+
const arrayOffset = read(0);
|
|
68
|
+
const length = read(arrayOffset);
|
|
69
|
+
if (length !== count)
|
|
70
|
+
throw new Error("multicall: result count mismatch");
|
|
71
|
+
const base = arrayOffset + 32;
|
|
72
|
+
const results = [];
|
|
73
|
+
for (let index = 0; index < length; index += 1) {
|
|
74
|
+
const tupleOffset = base + read(base + index * 32);
|
|
75
|
+
const success = read(tupleOffset) === 1;
|
|
76
|
+
const bytesOffset = tupleOffset + read(tupleOffset + 32);
|
|
77
|
+
const bytesLength = read(bytesOffset);
|
|
78
|
+
const start = (bytesOffset + 32) * 2;
|
|
79
|
+
const body = raw.slice(start, start + bytesLength * 2);
|
|
80
|
+
if (body.length !== bytesLength * 2)
|
|
81
|
+
throw new Error("multicall: truncated response");
|
|
82
|
+
results.push({ success, data: `0x${body}` });
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
function jsonRpcRequest(method, params, id) {
|
|
87
|
+
return { jsonrpc: "2.0", id, method, params };
|
|
88
|
+
}
|
|
89
|
+
function callRequest(call, id) {
|
|
90
|
+
return jsonRpcRequest("eth_call", [{ to: call.to, data: call.data }, "latest"], id);
|
|
91
|
+
}
|
|
92
|
+
function isRateLimited(entry) {
|
|
93
|
+
const error = entry?.error;
|
|
94
|
+
if (!error)
|
|
95
|
+
return false;
|
|
96
|
+
return error.code === -32005 || RATE_LIMITED.test(error.message ?? "");
|
|
26
97
|
}
|
|
27
98
|
function toResult(entry) {
|
|
28
99
|
if (!entry)
|
|
29
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
|
+
}
|
|
30
107
|
if (entry.error)
|
|
31
108
|
return { ok: false, error: new Error(`evm rpc: ${entry.error.message}`) };
|
|
32
109
|
return { ok: true, value: entry.result ?? "0x" };
|
|
33
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
|
+
}
|
|
34
121
|
export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
35
122
|
const fetchImpl = options.fetch ??
|
|
36
123
|
((input, init) => globalThis.fetch(input, init));
|
|
37
124
|
const onRequest = options.onRequest;
|
|
125
|
+
const useMulticall = options.multicall ?? false;
|
|
38
126
|
const batchers = new Map();
|
|
39
|
-
const
|
|
127
|
+
const modes = new Map();
|
|
128
|
+
const probes = new Map();
|
|
40
129
|
const post = async (endpoint, body, items, attempt) => {
|
|
130
|
+
let response;
|
|
41
131
|
try {
|
|
42
|
-
|
|
132
|
+
response = await fetchImpl(endpoint, {
|
|
43
133
|
method: "POST",
|
|
44
134
|
headers: { "Content-Type": "application/json" },
|
|
45
|
-
body: JSON.stringify(body)
|
|
135
|
+
body: JSON.stringify(body),
|
|
136
|
+
signal: options.signal
|
|
46
137
|
});
|
|
47
|
-
onRequest?.({ items, attempt, status: response.status });
|
|
48
|
-
return response;
|
|
49
138
|
}
|
|
50
139
|
catch (error) {
|
|
140
|
+
if (isAbortError(error))
|
|
141
|
+
throw error;
|
|
51
142
|
onRequest?.({ items, attempt, status: "network-error" });
|
|
52
143
|
throw new TransientError(`evm rpc: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
144
|
}
|
|
54
|
-
|
|
55
|
-
const send = async (endpoint, calls, attempt) => {
|
|
56
|
-
const batched = calls.length > 1;
|
|
57
|
-
if (batched && singleOnly.has(endpoint)) {
|
|
58
|
-
throw new ChunkTooLargeError("evm rpc: batch unsupported");
|
|
59
|
-
}
|
|
60
|
-
const body = batched
|
|
61
|
-
? calls.map((call, id) => jsonRpcRequest(call, id))
|
|
62
|
-
: jsonRpcRequest(calls[0], 1);
|
|
63
|
-
const response = await post(endpoint, body, calls.length, attempt);
|
|
145
|
+
onRequest?.({ items, attempt, status: response.status });
|
|
64
146
|
if (isTransientStatus(response.status)) {
|
|
65
147
|
throw new TransientError(`evm rpc ${response.status}`, retryAfterMs(response.headers?.get("retry-after")));
|
|
66
148
|
}
|
|
67
|
-
if (!response.ok)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
throw new HttpError(response.status);
|
|
151
|
+
return response;
|
|
152
|
+
};
|
|
153
|
+
const rpc = async (endpoint, method, params, items, attempt) => {
|
|
154
|
+
const response = await post(endpoint, jsonRpcRequest(method, params, 1), items, attempt);
|
|
155
|
+
const parsed = (await response.json());
|
|
156
|
+
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
157
|
+
if (isRateLimited(entry))
|
|
158
|
+
throw new TransientError(`evm rpc: ${entry?.error?.message}`);
|
|
159
|
+
return entry ?? {};
|
|
160
|
+
};
|
|
161
|
+
const detectMode = (endpoint, attempt) => {
|
|
162
|
+
const known = modes.get(endpoint);
|
|
163
|
+
if (known)
|
|
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";
|
|
174
|
+
try {
|
|
175
|
+
const code = await rpc(endpoint, "eth_getCode", [MULTICALL3_ADDRESS, "latest"], 1, attempt);
|
|
176
|
+
if (!code.error && stripHex(code.result ?? "").length > 2)
|
|
177
|
+
mode = "multicall";
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof TransientError || isAbortError(error))
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
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;
|
|
190
|
+
};
|
|
191
|
+
const sendMulticall = async (endpoint, calls, attempt) => {
|
|
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
|
+
}
|
|
201
|
+
if (entry.error || !entry.result)
|
|
202
|
+
return undefined;
|
|
203
|
+
let decoded;
|
|
204
|
+
try {
|
|
205
|
+
decoded = decodeAggregate3(entry.result, calls.length);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
return decoded.map(result => result.success
|
|
211
|
+
? { ok: true, value: result.data }
|
|
212
|
+
: { ok: false, error: new Error("evm rpc: execution reverted") });
|
|
213
|
+
};
|
|
214
|
+
const sendBatch = async (endpoint, calls, attempt) => {
|
|
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);
|
|
71
223
|
}
|
|
72
|
-
throw
|
|
224
|
+
throw error;
|
|
73
225
|
}
|
|
74
226
|
const parsed = (await response.json());
|
|
75
|
-
if (!batched) {
|
|
76
|
-
return [toResult(Array.isArray(parsed) ? parsed[0] : parsed)];
|
|
77
|
-
}
|
|
78
227
|
if (!Array.isArray(parsed)) {
|
|
79
|
-
|
|
228
|
+
modes.set(endpoint, "single");
|
|
80
229
|
throw new ChunkTooLargeError("evm rpc: batch unsupported");
|
|
81
230
|
}
|
|
82
231
|
const byId = new Map(parsed.map(entry => [Number(entry.id), entry]));
|
|
83
232
|
return calls.map((_, id) => toResult(byId.get(id)));
|
|
84
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
|
+
};
|
|
239
|
+
const send = async (endpoint, calls, attempt) => {
|
|
240
|
+
if (calls.length === 1)
|
|
241
|
+
return sendSingle(endpoint, calls[0], attempt);
|
|
242
|
+
const mode = await detectMode(endpoint, attempt);
|
|
243
|
+
if (mode === "single")
|
|
244
|
+
throw new ChunkTooLargeError("evm rpc: batch unsupported");
|
|
245
|
+
if (mode === "multicall") {
|
|
246
|
+
const aggregated = await sendMulticall(endpoint, calls, attempt);
|
|
247
|
+
if (aggregated)
|
|
248
|
+
return aggregated;
|
|
249
|
+
modes.set(endpoint, "batch");
|
|
250
|
+
}
|
|
251
|
+
return sendBatch(endpoint, calls, attempt);
|
|
252
|
+
};
|
|
85
253
|
const batcherFor = (endpoint) => {
|
|
86
254
|
const existing = batchers.get(endpoint);
|
|
87
255
|
if (existing)
|
|
@@ -93,7 +261,9 @@ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
|
93
261
|
concurrency: options.concurrency,
|
|
94
262
|
retries: options.retries,
|
|
95
263
|
baseDelayMs: options.baseDelayMs,
|
|
96
|
-
|
|
264
|
+
maxDelayMs: options.maxDelayMs,
|
|
265
|
+
sleep: options.sleep,
|
|
266
|
+
signal: options.signal
|
|
97
267
|
});
|
|
98
268
|
batchers.set(endpoint, batcher);
|
|
99
269
|
return batcher;
|
|
@@ -103,6 +273,7 @@ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
|
103
273
|
const endpoint = override ?? endpoints[chainId];
|
|
104
274
|
if (!endpoint)
|
|
105
275
|
throw new Error(`no evm endpoint for chain ${chainId}`);
|
|
276
|
+
validateCall(to, data);
|
|
106
277
|
return batcherFor(endpoint).enqueue({ to, data });
|
|
107
278
|
}
|
|
108
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;
|