@ember-finance/sdk 2.11.0-pre.3 → 2.11.0-pre.5

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.
@@ -0,0 +1,61 @@
1
+ export interface TransportRequest {
2
+ items: number;
3
+ attempt: number;
4
+ status: number | "network-error";
5
+ }
6
+ export type BatchResult<T> = {
7
+ ok: true;
8
+ value: T;
9
+ } | {
10
+ ok: false;
11
+ error: Error;
12
+ };
13
+ export declare class TransientError extends Error {
14
+ readonly retryAfterMs?: number;
15
+ constructor(message: string, retryAfterMs?: number);
16
+ }
17
+ export declare class ChunkTooLargeError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ export declare function retryAfterMs(header: string | null | undefined): number | undefined;
21
+ export declare function isTransientStatus(status: number): boolean;
22
+ export interface BatcherOptions<Item, Result> {
23
+ send: (items: Item[], attempt: number) => Promise<BatchResult<Result>[]>;
24
+ key: (item: Item) => string;
25
+ weight?: (item: Item) => number;
26
+ bytes?: (item: Item) => number;
27
+ maxWeight?: number;
28
+ maxBytes?: number;
29
+ concurrency?: number;
30
+ retries?: number;
31
+ baseDelayMs?: number;
32
+ sleep?: (ms: number) => Promise<void>;
33
+ schedule?: (flush: () => void) => void;
34
+ }
35
+ export declare class Batcher<Item, Result> {
36
+ private readonly send;
37
+ private readonly key;
38
+ private readonly weight;
39
+ private readonly bytes;
40
+ private readonly maxWeight;
41
+ private readonly maxBytes;
42
+ private readonly concurrency;
43
+ private readonly retries;
44
+ private readonly baseDelayMs;
45
+ private readonly sleep;
46
+ private readonly schedule;
47
+ private readonly queue;
48
+ private readonly inFlight;
49
+ private readonly waiting;
50
+ private active;
51
+ private scheduled;
52
+ constructor(options: BatcherOptions<Item, Result>);
53
+ enqueue(item: Item): Promise<Result>;
54
+ private scheduleFlush;
55
+ private flush;
56
+ private chunk;
57
+ private run;
58
+ private backoff;
59
+ private acquire;
60
+ private release;
61
+ }
@@ -0,0 +1,166 @@
1
+ export class TransientError extends Error {
2
+ constructor(message, retryAfterMs) {
3
+ super(message);
4
+ this.name = "TransientError";
5
+ this.retryAfterMs = retryAfterMs;
6
+ }
7
+ }
8
+ export class ChunkTooLargeError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "ChunkTooLargeError";
12
+ }
13
+ }
14
+ export function retryAfterMs(header) {
15
+ if (!header)
16
+ return undefined;
17
+ const seconds = Number(header);
18
+ if (Number.isFinite(seconds))
19
+ return Math.max(0, seconds * 1000);
20
+ const at = Date.parse(header);
21
+ return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
22
+ }
23
+ export function isTransientStatus(status) {
24
+ return status === 429 || status >= 500;
25
+ }
26
+ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
27
+ const defaultSchedule = (flush) => {
28
+ setTimeout(flush, 0);
29
+ };
30
+ export class Batcher {
31
+ constructor(options) {
32
+ this.queue = [];
33
+ this.inFlight = new Map();
34
+ this.waiting = [];
35
+ this.active = 0;
36
+ this.scheduled = false;
37
+ this.send = options.send;
38
+ this.key = options.key;
39
+ this.weight = options.weight ?? (() => 1);
40
+ this.bytes = options.bytes ?? (() => 0);
41
+ this.maxWeight = options.maxWeight ?? 10;
42
+ this.maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY;
43
+ this.concurrency = options.concurrency ?? 3;
44
+ this.retries = options.retries ?? 3;
45
+ this.baseDelayMs = options.baseDelayMs ?? 300;
46
+ this.sleep = options.sleep ?? defaultSleep;
47
+ this.schedule = options.schedule ?? defaultSchedule;
48
+ }
49
+ enqueue(item) {
50
+ const key = this.key(item);
51
+ const shared = this.inFlight.get(key);
52
+ if (shared)
53
+ return shared;
54
+ const promise = new Promise((resolve, reject) => {
55
+ this.queue.push({ item, resolve, reject });
56
+ });
57
+ this.inFlight.set(key, promise);
58
+ const forget = () => {
59
+ if (this.inFlight.get(key) === promise)
60
+ this.inFlight.delete(key);
61
+ };
62
+ promise.then(forget, forget);
63
+ this.scheduleFlush();
64
+ return promise;
65
+ }
66
+ scheduleFlush() {
67
+ if (this.scheduled)
68
+ return;
69
+ this.scheduled = true;
70
+ this.schedule(() => {
71
+ this.scheduled = false;
72
+ this.flush();
73
+ });
74
+ }
75
+ flush() {
76
+ const pending = this.queue.splice(0);
77
+ for (const chunk of this.chunk(pending)) {
78
+ void this.run(chunk, 1);
79
+ }
80
+ }
81
+ chunk(pending) {
82
+ const chunks = [];
83
+ let current = [];
84
+ let weight = 0;
85
+ let bytes = 0;
86
+ for (const entry of pending) {
87
+ const entryWeight = this.weight(entry.item);
88
+ const entryBytes = this.bytes(entry.item);
89
+ const overflows = weight + entryWeight > this.maxWeight ||
90
+ bytes + entryBytes > this.maxBytes;
91
+ if (current.length > 0 && overflows) {
92
+ chunks.push(current);
93
+ current = [];
94
+ weight = 0;
95
+ bytes = 0;
96
+ }
97
+ current.push(entry);
98
+ weight += entryWeight;
99
+ bytes += entryBytes;
100
+ }
101
+ if (current.length > 0)
102
+ chunks.push(current);
103
+ return chunks;
104
+ }
105
+ async run(chunk, attempt) {
106
+ await this.acquire();
107
+ let results;
108
+ try {
109
+ results = await this.send(chunk.map(entry => entry.item), attempt);
110
+ }
111
+ catch (error) {
112
+ this.release();
113
+ if (error instanceof ChunkTooLargeError && chunk.length > 1) {
114
+ const half = Math.ceil(chunk.length / 2);
115
+ void this.run(chunk.slice(0, half), 1);
116
+ void this.run(chunk.slice(half), 1);
117
+ return;
118
+ }
119
+ if (error instanceof TransientError && attempt <= this.retries) {
120
+ await this.sleep(error.retryAfterMs ?? this.backoff(attempt));
121
+ void this.run(chunk, attempt + 1);
122
+ return;
123
+ }
124
+ const failure = error instanceof Error ? error : new Error(String(error));
125
+ for (const entry of chunk)
126
+ entry.reject(failure);
127
+ return;
128
+ }
129
+ this.release();
130
+ chunk.forEach((entry, index) => {
131
+ const result = results[index];
132
+ if (result === undefined) {
133
+ entry.reject(new Error("batch: no result for item"));
134
+ return;
135
+ }
136
+ if (result.ok === true) {
137
+ entry.resolve(result.value);
138
+ return;
139
+ }
140
+ entry.reject(result.error);
141
+ });
142
+ }
143
+ backoff(attempt) {
144
+ const base = this.baseDelayMs * 2 ** (attempt - 1);
145
+ const jitter = base * 0.3 * (Math.random() * 2 - 1);
146
+ return Math.round(base + jitter);
147
+ }
148
+ acquire() {
149
+ if (this.active < this.concurrency) {
150
+ this.active += 1;
151
+ return Promise.resolve();
152
+ }
153
+ return new Promise(resolve => {
154
+ this.waiting.push(() => {
155
+ this.active += 1;
156
+ resolve();
157
+ });
158
+ });
159
+ }
160
+ release() {
161
+ this.active -= 1;
162
+ const next = this.waiting.shift();
163
+ if (next)
164
+ next();
165
+ }
166
+ }
@@ -8,6 +8,7 @@ export declare class PlacementsClient {
8
8
  constructor(ctx: PlacementsContext);
9
9
  /** One vault, one account. */
10
10
  forAccount(vaultId: string, account: string, options?: ForAccountOptions): Promise<AccountShares[]>;
11
+ forAccounts(vaultIds: string[], account: string, options?: ForAccountOptions): Promise<AccountShares[]>;
11
12
  private sharesForCoin;
12
13
  private walletBalance;
13
14
  private coveredBalance;
@@ -24,6 +24,16 @@ function addressFits(chain, account) {
24
24
  return false;
25
25
  return chain === "evm" ? hex.length <= 40 : hex.length <= 64;
26
26
  }
27
+ function unresolvedVault(vaultId, reason) {
28
+ return {
29
+ vaultId,
30
+ coin: { coin: "", chain: { type: "sui" }, decimals: 0 },
31
+ available: "0",
32
+ covered: undefined,
33
+ placements: [],
34
+ failures: [{ protocol: "vault", stage: "discover", reason }]
35
+ };
36
+ }
27
37
  export class PlacementsClient {
28
38
  constructor(ctx) {
29
39
  this.ctx = ctx;
@@ -35,6 +45,24 @@ export class PlacementsClient {
35
45
  const coins = await this.ctx.vaults.coinsForVault(vaultId);
36
46
  return Promise.all(coins.map(coin => this.sharesForCoin(vaultId, coin, account, options)));
37
47
  }
48
+ async forAccounts(vaultIds, account, options = {}) {
49
+ if (!this.ctx.vaults)
50
+ throw new Error("no vault resolver configured");
51
+ const vaults = this.ctx.vaults;
52
+ const resolved = await Promise.all(vaultIds.map(async (vaultId) => {
53
+ try {
54
+ return { vaultId, coins: await vaults.coinsForVault(vaultId) };
55
+ }
56
+ catch (error) {
57
+ return { vaultId, coins: undefined, reason: reasonOf(error) };
58
+ }
59
+ }));
60
+ return Promise.all(resolved.flatMap(entry => entry.coins
61
+ ? entry.coins.map(coin => this.sharesForCoin(entry.vaultId, coin, account, options))
62
+ : [
63
+ Promise.resolve(unresolvedVault(entry.vaultId, entry.reason ?? ""))
64
+ ]));
65
+ }
38
66
  async sharesForCoin(vaultId, coin, account, options) {
39
67
  const failures = [];
40
68
  // A vault on several chains issues a coin on each, so for any one
@@ -1,11 +1,27 @@
1
1
  import { EvmReader } from "./types.js";
2
+ import { TransportRequest } from "./batch.js";
2
3
  export declare function encodeBalanceOf(owner: string): string;
3
4
  export declare function asContractAddress(address: string): string;
4
5
  export declare function decodeUint256(hex: string): bigint;
5
- /**
6
- * JSON-RPC reader over `fetch`, so it runs unchanged in the browser.
7
- *
8
- * Pass a dedicated endpoint per chain in production: the public RPCs used for
9
- * development are rate-limited and carry no SLA.
10
- */
11
- export declare function jsonRpcEvmReader(endpoints?: Record<number, string>): EvmReader;
6
+ export interface JsonRpcEvmReaderOptions {
7
+ maxCalls?: number;
8
+ concurrency?: number;
9
+ retries?: number;
10
+ baseDelayMs?: number;
11
+ sleep?: (ms: number) => Promise<void>;
12
+ fetch?: typeof globalThis.fetch;
13
+ onRequest?: (request: TransportRequest) => void;
14
+ multicall?: boolean;
15
+ }
16
+ interface EvmCall {
17
+ to: string;
18
+ data: string;
19
+ }
20
+ export declare const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
21
+ export declare function encodeAggregate3(calls: EvmCall[]): string;
22
+ export declare function decodeAggregate3(data: string, count: number): {
23
+ success: boolean;
24
+ data: string;
25
+ }[];
26
+ export declare function jsonRpcEvmReader(endpoints?: Record<number, string>, options?: JsonRpcEvmReaderOptions): EvmReader;
27
+ export {};
@@ -1,3 +1,4 @@
1
+ import { Batcher, ChunkTooLargeError, TransientError, isTransientStatus, retryAfterMs } from "./batch.js";
1
2
  /** `balanceOf(address)` */
2
3
  const BALANCE_OF_SELECTOR = "0x70a08231";
3
4
  export function encodeBalanceOf(owner) {
@@ -15,34 +16,215 @@ export function decodeUint256(hex) {
15
16
  return 0n;
16
17
  return BigInt(hex);
17
18
  }
18
- /**
19
- * JSON-RPC reader over `fetch`, so it runs unchanged in the browser.
20
- *
21
- * Pass a dedicated endpoint per chain in production: the public RPCs used for
22
- * development are rate-limited and carry no SLA.
23
- */
24
- export function jsonRpcEvmReader(endpoints = {}) {
25
- return {
26
- async call(chainId, to, data, override) {
27
- const endpoint = override ?? endpoints[chainId];
28
- if (!endpoint)
29
- throw new Error(`no evm endpoint for chain ${chainId}`);
30
- const response = await fetch(endpoint, {
19
+ export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
20
+ const AGGREGATE3_SELECTOR = "0x82ad56cb";
21
+ const RATE_LIMITED = /too many requests|rate ?limit/i;
22
+ function stripHex(value) {
23
+ return value.trim().replace(/^0x/i, "");
24
+ }
25
+ function word(value) {
26
+ return BigInt(value).toString(16).padStart(64, "0");
27
+ }
28
+ function addressWord(address) {
29
+ return stripHex(address).toLowerCase().padStart(64, "0");
30
+ }
31
+ function bytesWords(data) {
32
+ const raw = stripHex(data);
33
+ const length = raw.length / 2;
34
+ const padded = raw.padEnd(Math.ceil(length / 32) * 64, "0");
35
+ return word(length) + padded;
36
+ }
37
+ export function encodeAggregate3(calls) {
38
+ const tuples = calls.map(call => addressWord(call.to) + word(1) + word(0x60) + bytesWords(call.data));
39
+ let offset = calls.length * 32;
40
+ const offsets = tuples.map(tuple => {
41
+ const current = offset;
42
+ offset += tuple.length / 2;
43
+ return word(current);
44
+ });
45
+ return (AGGREGATE3_SELECTOR +
46
+ word(0x20) +
47
+ word(calls.length) +
48
+ offsets.join("") +
49
+ tuples.join(""));
50
+ }
51
+ export function decodeAggregate3(data, count) {
52
+ const raw = stripHex(data);
53
+ const read = (byteOffset) => {
54
+ const slice = raw.slice(byteOffset * 2, byteOffset * 2 + 64);
55
+ if (slice.length !== 64)
56
+ throw new Error("multicall: truncated response");
57
+ return Number(BigInt(`0x${slice}`));
58
+ };
59
+ const arrayOffset = read(0);
60
+ const length = read(arrayOffset);
61
+ if (length !== count)
62
+ throw new Error("multicall: result count mismatch");
63
+ const base = arrayOffset + 32;
64
+ const results = [];
65
+ for (let index = 0; index < length; index += 1) {
66
+ const tupleOffset = base + read(base + index * 32);
67
+ const success = read(tupleOffset) === 1;
68
+ const bytesOffset = tupleOffset + read(tupleOffset + 32);
69
+ const bytesLength = read(bytesOffset);
70
+ const start = (bytesOffset + 32) * 2;
71
+ const body = raw.slice(start, start + bytesLength * 2);
72
+ if (body.length !== bytesLength * 2)
73
+ throw new Error("multicall: truncated response");
74
+ results.push({ success, data: `0x${body}` });
75
+ }
76
+ return results;
77
+ }
78
+ function jsonRpcRequest(method, params, id) {
79
+ return { jsonrpc: "2.0", id, method, params };
80
+ }
81
+ function callRequest(call, id) {
82
+ return jsonRpcRequest("eth_call", [{ to: call.to, data: call.data }, "latest"], id);
83
+ }
84
+ function isRateLimited(entry) {
85
+ const error = entry?.error;
86
+ if (!error)
87
+ return false;
88
+ return error.code === -32005 || RATE_LIMITED.test(error.message ?? "");
89
+ }
90
+ function toResult(entry) {
91
+ if (!entry)
92
+ return { ok: false, error: new Error("evm rpc: no result for call") };
93
+ if (entry.error)
94
+ return { ok: false, error: new Error(`evm rpc: ${entry.error.message}`) };
95
+ return { ok: true, value: entry.result ?? "0x" };
96
+ }
97
+ export function jsonRpcEvmReader(endpoints = {}, options = {}) {
98
+ const fetchImpl = options.fetch ??
99
+ ((input, init) => globalThis.fetch(input, init));
100
+ const onRequest = options.onRequest;
101
+ const useMulticall = options.multicall ?? true;
102
+ const batchers = new Map();
103
+ const modes = new Map();
104
+ const post = async (endpoint, body, items, attempt) => {
105
+ let response;
106
+ try {
107
+ response = await fetchImpl(endpoint, {
31
108
  method: "POST",
32
109
  headers: { "Content-Type": "application/json" },
33
- body: JSON.stringify({
34
- jsonrpc: "2.0",
35
- id: 1,
36
- method: "eth_call",
37
- params: [{ to, data }, "latest"]
38
- })
110
+ body: JSON.stringify(body)
39
111
  });
112
+ }
113
+ catch (error) {
114
+ onRequest?.({ items, attempt, status: "network-error" });
115
+ throw new TransientError(`evm rpc: ${error instanceof Error ? error.message : String(error)}`);
116
+ }
117
+ onRequest?.({ items, attempt, status: response.status });
118
+ if (isTransientStatus(response.status)) {
119
+ throw new TransientError(`evm rpc ${response.status}`, retryAfterMs(response.headers?.get("retry-after")));
120
+ }
121
+ return response;
122
+ };
123
+ const rpc = async (endpoint, method, params, items, attempt) => {
124
+ const response = await post(endpoint, jsonRpcRequest(method, params, 1), items, attempt);
125
+ if (!response.ok)
126
+ throw new Error(`evm rpc ${response.status}`);
127
+ const parsed = (await response.json());
128
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
129
+ if (isRateLimited(entry))
130
+ throw new TransientError(`evm rpc: ${entry?.error?.message}`);
131
+ return entry ?? {};
132
+ };
133
+ const detectMode = async (endpoint, attempt) => {
134
+ const known = modes.get(endpoint);
135
+ if (known)
136
+ return known;
137
+ let mode = "batch";
138
+ if (useMulticall) {
139
+ try {
140
+ const code = await rpc(endpoint, "eth_getCode", [MULTICALL3_ADDRESS, "latest"], 1, attempt);
141
+ if (!code.error && stripHex(code.result ?? "").length > 2)
142
+ mode = "multicall";
143
+ }
144
+ catch (error) {
145
+ if (error instanceof TransientError)
146
+ throw error;
147
+ }
148
+ }
149
+ modes.set(endpoint, mode);
150
+ return mode;
151
+ };
152
+ const sendMulticall = async (endpoint, calls, attempt) => {
153
+ const entry = await rpc(endpoint, "eth_call", [{ to: MULTICALL3_ADDRESS, data: encodeAggregate3(calls) }, "latest"], calls.length, attempt);
154
+ if (entry.error || !entry.result)
155
+ return undefined;
156
+ let decoded;
157
+ try {
158
+ decoded = decodeAggregate3(entry.result, calls.length);
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ return decoded.map(result => result.success
164
+ ? { ok: true, value: result.data === "" ? "0x" : result.data }
165
+ : { ok: false, error: new Error("evm rpc: execution reverted") });
166
+ };
167
+ const sendBatch = async (endpoint, calls, attempt) => {
168
+ const response = await post(endpoint, calls.map((call, id) => callRequest(call, id)), calls.length, attempt);
169
+ if (!response.ok) {
170
+ modes.set(endpoint, "single");
171
+ throw new ChunkTooLargeError(`evm rpc ${response.status}`);
172
+ }
173
+ const parsed = (await response.json());
174
+ if (!Array.isArray(parsed)) {
175
+ modes.set(endpoint, "single");
176
+ throw new ChunkTooLargeError("evm rpc: batch unsupported");
177
+ }
178
+ const limited = parsed.find(isRateLimited);
179
+ if (limited)
180
+ throw new TransientError(`evm rpc: ${limited.error?.message}`);
181
+ const byId = new Map(parsed.map(entry => [Number(entry.id), entry]));
182
+ return calls.map((_, id) => toResult(byId.get(id)));
183
+ };
184
+ const send = async (endpoint, calls, attempt) => {
185
+ if (calls.length === 1) {
186
+ const response = await post(endpoint, callRequest(calls[0], 1), 1, attempt);
40
187
  if (!response.ok)
41
188
  throw new Error(`evm rpc ${response.status}`);
42
- const body = (await response.json());
43
- if (body.error)
44
- throw new Error(`evm rpc: ${body.error.message}`);
45
- return body.result ?? "0x";
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
+ }
195
+ const mode = await detectMode(endpoint, attempt);
196
+ if (mode === "single")
197
+ throw new ChunkTooLargeError("evm rpc: batch unsupported");
198
+ if (mode === "multicall") {
199
+ const aggregated = await sendMulticall(endpoint, calls, attempt);
200
+ if (aggregated)
201
+ return aggregated;
202
+ modes.set(endpoint, "batch");
203
+ }
204
+ return sendBatch(endpoint, calls, attempt);
205
+ };
206
+ const batcherFor = (endpoint) => {
207
+ const existing = batchers.get(endpoint);
208
+ if (existing)
209
+ return existing;
210
+ const batcher = new Batcher({
211
+ send: (calls, attempt) => send(endpoint, calls, attempt),
212
+ key: call => `${call.to.toLowerCase()}:${call.data.toLowerCase()}`,
213
+ maxWeight: options.maxCalls ?? 20,
214
+ concurrency: options.concurrency,
215
+ retries: options.retries,
216
+ baseDelayMs: options.baseDelayMs,
217
+ sleep: options.sleep
218
+ });
219
+ batchers.set(endpoint, batcher);
220
+ return batcher;
221
+ };
222
+ return {
223
+ async call(chainId, to, data, override) {
224
+ const endpoint = override ?? endpoints[chainId];
225
+ if (!endpoint)
226
+ throw new Error(`no evm endpoint for chain ${chainId}`);
227
+ return batcherFor(endpoint).enqueue({ to, data });
46
228
  }
47
229
  };
48
230
  }
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
+ export * from "./batch.js";
2
3
  export * from "./registry.js";
3
4
  export * from "./client.js";
4
5
  export * from "./pointers.js";
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
+ export * from "./batch.js";
2
3
  export * from "./registry.js";
3
4
  export * from "./client.js";
4
5
  export * from "./pointers.js";
@@ -1,4 +1,5 @@
1
1
  import { SuiReader } from "./types.js";
2
+ import { TransportRequest } from "./batch.js";
2
3
  /**
3
4
  * Canonical form for a Sui coin type: `0x` stripped from the address segment,
4
5
  * lowercased and left-padded to 64 hex chars.
@@ -28,8 +29,16 @@ export declare function tableId(field: unknown): string | undefined;
28
29
  */
29
30
  export declare function decimalToBaseUnits(field: unknown): bigint;
30
31
  export declare function toBigInt(field: unknown): bigint;
31
- /**
32
- * GraphQL-backed reader. Uses `fetch` only, so it runs in the browser as well
33
- * as Node: the Sui GraphQL endpoint answers with `access-control-allow-origin: *`.
34
- */
35
- export declare function graphqlSuiReader(endpoint?: string): SuiReader;
32
+ export declare const DEFAULT_SUI_GRAPHQL_ENDPOINT = "https://graphql.mainnet.sui.io/graphql";
33
+ export interface GraphqlSuiReaderOptions {
34
+ endpoint?: string;
35
+ maxRootFields?: number;
36
+ maxBytes?: number;
37
+ concurrency?: number;
38
+ retries?: number;
39
+ baseDelayMs?: number;
40
+ sleep?: (ms: number) => Promise<void>;
41
+ fetch?: typeof globalThis.fetch;
42
+ onRequest?: (request: TransportRequest) => void;
43
+ }
44
+ export declare function graphqlSuiReader(options?: string | GraphqlSuiReaderOptions): SuiReader;
@@ -1,3 +1,4 @@
1
+ import { Batcher, ChunkTooLargeError, TransientError, isTransientStatus, retryAfterMs } from "./batch.js";
1
2
  const GRAPHQL_PAGE_SIZE = 50;
2
3
  /**
3
4
  * Canonical form for a Sui coin type: `0x` stripped from the address segment,
@@ -84,59 +85,140 @@ export function toBigInt(field) {
84
85
  return 0n;
85
86
  return BigInt(String(field));
86
87
  }
87
- async function query(endpoint, gql) {
88
- const response = await fetch(endpoint, {
89
- method: "POST",
90
- headers: { "Content-Type": "application/json" },
91
- body: JSON.stringify({ query: gql })
88
+ export const DEFAULT_SUI_GRAPHQL_ENDPOINT = "https://graphql.mainnet.sui.io/graphql";
89
+ const OBJECTS_ALIAS = "objects";
90
+ const OBJECT_SELECTION = "{ address asMoveObject { contents { type { repr } json } } }";
91
+ const OBJECT_KEY_WEIGHT = 0.25;
92
+ const OBJECT_KEY_BYTES = 90;
93
+ function normalizeAddress(address) {
94
+ return address.trim().toLowerCase().replace(/^0x/, "").padStart(64, "0");
95
+ }
96
+ function buildDocument(chunk) {
97
+ const roots = [];
98
+ const ids = chunk.flatMap(item => (item.kind === "object" ? [item.id] : []));
99
+ if (ids.length > 0) {
100
+ const keys = ids.map(id => `{ address: "${id}" }`).join(", ");
101
+ roots.push(`${OBJECTS_ALIAS}: multiGetObjects(keys: [${keys}]) ${OBJECT_SELECTION}`);
102
+ }
103
+ chunk.forEach((item, index) => {
104
+ if (item.kind === "field")
105
+ roots.push(`r${index}: ${item.field}`);
92
106
  });
93
- if (!response.ok) {
94
- throw new Error(`sui graphql ${response.status}`);
107
+ return `{ ${roots.join(" ")} }`;
108
+ }
109
+ function distribute(chunk, body) {
110
+ const pathErrors = new Map();
111
+ const rootErrors = [];
112
+ for (const error of body.errors ?? []) {
113
+ const alias = error.path?.[0];
114
+ if (typeof alias === "string") {
115
+ pathErrors.set(alias, error.message);
116
+ }
117
+ else {
118
+ rootErrors.push(error.message);
119
+ }
95
120
  }
96
- const body = (await response.json());
97
- if (body.errors?.length) {
98
- throw new Error(`sui graphql: ${body.errors.map(e => e.message).join("; ")}`);
121
+ if (rootErrors.length > 0) {
122
+ const message = `sui graphql: ${rootErrors.join("; ")}`;
123
+ if (chunk.length > 1)
124
+ throw new ChunkTooLargeError(message);
125
+ throw new Error(message);
99
126
  }
100
- if (!body.data)
101
- throw new Error("sui graphql: empty response");
102
- return body.data;
127
+ const data = body.data ?? {};
128
+ const objectsByAddress = new Map();
129
+ for (const node of data[OBJECTS_ALIAS] ?? []) {
130
+ const contents = node?.asMoveObject?.contents;
131
+ if (!node || !contents)
132
+ continue;
133
+ objectsByAddress.set(normalizeAddress(node.address), {
134
+ address: node.address,
135
+ type: contents.type.repr,
136
+ json: contents.json
137
+ });
138
+ }
139
+ 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
+ if (item.kind === "object") {
146
+ return { ok: true, value: objectsByAddress.get(normalizeAddress(item.id)) };
147
+ }
148
+ return { ok: true, value: data[alias] };
149
+ });
103
150
  }
104
- /**
105
- * GraphQL-backed reader. Uses `fetch` only, so it runs in the browser as well
106
- * as Node: the Sui GraphQL endpoint answers with `access-control-allow-origin: *`.
107
- */
108
- export function graphqlSuiReader(endpoint = "https://graphql.mainnet.sui.io/graphql") {
151
+ export function graphqlSuiReader(options = {}) {
152
+ const config = typeof options === "string" ? { endpoint: options } : options;
153
+ const endpoint = config.endpoint ?? DEFAULT_SUI_GRAPHQL_ENDPOINT;
154
+ const fetchImpl = config.fetch ??
155
+ ((input, init) => globalThis.fetch(input, init));
156
+ const onRequest = config.onRequest;
157
+ const send = async (chunk, attempt) => {
158
+ const document = buildDocument(chunk);
159
+ let response;
160
+ try {
161
+ response = await fetchImpl(endpoint, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({ query: document })
165
+ });
166
+ }
167
+ catch (error) {
168
+ onRequest?.({ items: chunk.length, attempt, status: "network-error" });
169
+ throw new TransientError(`sui graphql: ${error instanceof Error ? error.message : String(error)}`);
170
+ }
171
+ onRequest?.({ items: chunk.length, attempt, status: response.status });
172
+ if (isTransientStatus(response.status)) {
173
+ throw new TransientError(`sui graphql ${response.status}`, retryAfterMs(response.headers?.get("retry-after")));
174
+ }
175
+ if (!response.ok) {
176
+ const message = `sui graphql ${response.status}`;
177
+ if (chunk.length > 1)
178
+ throw new ChunkTooLargeError(message);
179
+ throw new Error(message);
180
+ }
181
+ const body = (await response.json());
182
+ if (!body.data && !body.errors?.length) {
183
+ throw new Error("sui graphql: empty response");
184
+ }
185
+ return distribute(chunk, body);
186
+ };
187
+ const batcher = new Batcher({
188
+ send,
189
+ key: item => item.kind === "object" ? `object:${normalizeAddress(item.id)}` : item.field,
190
+ weight: item => (item.kind === "object" ? OBJECT_KEY_WEIGHT : 1),
191
+ bytes: item => (item.kind === "object" ? OBJECT_KEY_BYTES : item.field.length),
192
+ maxWeight: config.maxRootFields ?? 10,
193
+ maxBytes: config.maxBytes ?? 4500,
194
+ concurrency: config.concurrency,
195
+ retries: config.retries,
196
+ baseDelayMs: config.baseDelayMs,
197
+ sleep: config.sleep
198
+ });
199
+ const field = (gql) => batcher.enqueue({ kind: "field", field: gql });
109
200
  return {
110
201
  async objects(ids) {
111
202
  const unique = [...new Set(ids)];
112
- if (unique.length === 0)
113
- return {};
114
- const keys = unique.map(id => `{ address: "${id}" }`).join(", ");
115
- const data = await query(endpoint, `{ multiGetObjects(keys: [${keys}]) { address asMoveObject { contents { type { repr } json } } } }`);
203
+ const found = await Promise.all(unique.map(id => batcher.enqueue({ kind: "object", id })));
116
204
  const out = {};
117
- for (const node of data.multiGetObjects ?? []) {
118
- const contents = node?.asMoveObject?.contents;
119
- if (!contents)
120
- continue;
121
- out[node.address] = {
122
- address: node.address,
123
- type: contents.type.repr,
124
- json: contents.json
125
- };
205
+ for (const object of found) {
206
+ if (object)
207
+ out[object.address] = object;
126
208
  }
127
209
  return out;
128
210
  },
129
211
  async balance(owner, coinType) {
130
- const data = await query(endpoint, `{ address(address: "${owner}") { balance(coinType: "${coinType}") { totalBalance } } }`);
131
- return data.address?.balance?.totalBalance ?? "0";
212
+ const data = await field(`address(address: "${owner}") { balance(coinType: "${coinType}") { totalBalance } }`);
213
+ return data?.balance?.totalBalance ?? "0";
132
214
  },
133
215
  async ownedObjects(owner, type) {
134
216
  const owned = [];
135
217
  let cursor = null;
136
218
  for (;;) {
137
219
  const after = cursor ? `, after: "${cursor}"` : "";
138
- const page = await query(endpoint, `{ address(address: "${owner}") { objects(first: ${GRAPHQL_PAGE_SIZE}${after}, filter: { type: "${type}" }) { pageInfo { hasNextPage endCursor } nodes { address contents { type { repr } json } } } } }`);
139
- const container = page.address?.objects;
220
+ const page = await field(`address(address: "${owner}") { objects(first: ${GRAPHQL_PAGE_SIZE}${after}, filter: { type: "${type}" }) { pageInfo { hasNextPage endCursor } nodes { address contents { type { repr } json } } } }`);
221
+ const container = page?.objects;
140
222
  if (!container)
141
223
  break;
142
224
  for (const node of container.nodes ?? []) {
@@ -155,19 +237,16 @@ export function graphqlSuiReader(endpoint = "https://graphql.mainnet.sui.io/grap
155
237
  return owned;
156
238
  },
157
239
  async dynamicField(parentId, key) {
158
- const data = await query(endpoint, `{ address(address: "${parentId}") { dynamicField(name: { type: "${key.type}", bcs: "${key.bcs}" }) { value { ... on MoveValue { json } } } } }`);
159
- return data.address?.dynamicField?.value?.json;
240
+ const data = await field(`address(address: "${parentId}") { dynamicField(name: { type: "${key.type}", bcs: "${key.bcs}" }) { value { ... on MoveValue { json } } } }`);
241
+ return data?.dynamicField?.value?.json;
160
242
  },
161
243
  async dynamicFields(parentId, limit) {
162
244
  const fields = [];
163
245
  let cursor = null;
164
- // Paged rather than fetched by name: addressing a table entry by key
165
- // needs the key BCS-encoded, and these tables are small enough
166
- // (tens of entries) that a full walk is cheaper than that machinery.
167
246
  for (;;) {
168
247
  const after = cursor ? `, after: "${cursor}"` : "";
169
- const page = await query(endpoint, `{ address(address: "${parentId}") { dynamicFields(first: ${GRAPHQL_PAGE_SIZE}${after}) { pageInfo { hasNextPage endCursor } nodes { name { json } value { ... on MoveValue { type { repr } json } } } } } }`);
170
- const container = page.address?.dynamicFields;
248
+ const page = await field(`address(address: "${parentId}") { dynamicFields(first: ${GRAPHQL_PAGE_SIZE}${after}) { pageInfo { hasNextPage endCursor } nodes { name { json } value { ... on MoveValue { type { repr } json } } } } }`);
249
+ const container = page?.dynamicFields;
171
250
  if (!container)
172
251
  break;
173
252
  for (const node of container.nodes ?? []) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ember-finance/sdk",
3
3
  "description": "Ember Protocol SDK",
4
- "version": "2.11.0-pre.3",
4
+ "version": "2.11.0-pre.5",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
7
7
  "main": "./dist/index.js",