@ember-finance/sdk 2.11.0-pre.2 → 2.11.0-pre.4
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/api/v2/models/coin-placement.d.ts +18 -0
- package/dist/src/placements/batch.d.ts +61 -0
- package/dist/src/placements/batch.js +166 -0
- package/dist/src/placements/client.d.ts +1 -0
- package/dist/src/placements/client.js +28 -0
- package/dist/src/placements/evm.d.ts +11 -7
- package/dist/src/placements/evm.js +84 -23
- package/dist/src/placements/index.d.ts +1 -0
- package/dist/src/placements/index.js +1 -0
- package/dist/src/placements/sui.d.ts +14 -5
- package/dist/src/placements/sui.js +121 -42
- package/package.json +1 -1
|
@@ -15,12 +15,30 @@
|
|
|
15
15
|
* @interface CoinPlacement
|
|
16
16
|
*/
|
|
17
17
|
export interface CoinPlacement {
|
|
18
|
+
/**
|
|
19
|
+
* Stable identity for this placement, for a client to key a list on. A protocol can list the same coin at several venues — the same pair at two fee tiers, or the two tranches of one market — so protocol and label together do not identify a row. Survives a re-sync.
|
|
20
|
+
* @type {string}
|
|
21
|
+
* @memberof CoinPlacement
|
|
22
|
+
*/
|
|
23
|
+
id: string;
|
|
18
24
|
/**
|
|
19
25
|
* Adapter id, e.g. \"bluefin-spot\"
|
|
20
26
|
* @type {string}
|
|
21
27
|
* @memberof CoinPlacement
|
|
22
28
|
*/
|
|
23
29
|
protocol: string;
|
|
30
|
+
/**
|
|
31
|
+
* The protocol\'s display name, e.g. \"Current Finance\". Absent when the protocol is not in our catalogue yet — render `protocol` then.
|
|
32
|
+
* @type {string}
|
|
33
|
+
* @memberof CoinPlacement
|
|
34
|
+
*/
|
|
35
|
+
protocolName?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Icon for the protocol. Absent when the protocol is not in our catalogue yet.
|
|
38
|
+
* @type {string}
|
|
39
|
+
* @memberof CoinPlacement
|
|
40
|
+
*/
|
|
41
|
+
protocolLogoUrl?: string;
|
|
24
42
|
/**
|
|
25
43
|
* Chain identifier as catalogued, e.g. \"sui-mainnet\".
|
|
26
44
|
* @type {string}
|
|
@@ -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,15 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
}
|
|
15
|
+
export declare function jsonRpcEvmReader(endpoints?: Record<number, string>, options?: JsonRpcEvmReaderOptions): EvmReader;
|
|
@@ -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,94 @@ export function decodeUint256(hex) {
|
|
|
15
16
|
return 0n;
|
|
16
17
|
return BigInt(hex);
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
function jsonRpcRequest(call, id) {
|
|
20
|
+
return {
|
|
21
|
+
jsonrpc: "2.0",
|
|
22
|
+
id,
|
|
23
|
+
method: "eth_call",
|
|
24
|
+
params: [{ to: call.to, data: call.data }, "latest"]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function toResult(entry) {
|
|
28
|
+
if (!entry)
|
|
29
|
+
return { ok: false, error: new Error("evm rpc: no result for call") };
|
|
30
|
+
if (entry.error)
|
|
31
|
+
return { ok: false, error: new Error(`evm rpc: ${entry.error.message}`) };
|
|
32
|
+
return { ok: true, value: entry.result ?? "0x" };
|
|
33
|
+
}
|
|
34
|
+
export function jsonRpcEvmReader(endpoints = {}, options = {}) {
|
|
35
|
+
const fetchImpl = options.fetch ??
|
|
36
|
+
((input, init) => globalThis.fetch(input, init));
|
|
37
|
+
const onRequest = options.onRequest;
|
|
38
|
+
const batchers = new Map();
|
|
39
|
+
const singleOnly = new Set();
|
|
40
|
+
const post = async (endpoint, body, items, attempt) => {
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetchImpl(endpoint, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
body: JSON.stringify(body)
|
|
46
|
+
});
|
|
47
|
+
onRequest?.({ items, attempt, status: response.status });
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
onRequest?.({ items, attempt, status: "network-error" });
|
|
52
|
+
throw new TransientError(`evm rpc: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
}
|
|
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);
|
|
64
|
+
if (isTransientStatus(response.status)) {
|
|
65
|
+
throw new TransientError(`evm rpc ${response.status}`, retryAfterMs(response.headers?.get("retry-after")));
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
if (batched) {
|
|
69
|
+
singleOnly.add(endpoint);
|
|
70
|
+
throw new ChunkTooLargeError(`evm rpc ${response.status}`);
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`evm rpc ${response.status}`);
|
|
73
|
+
}
|
|
74
|
+
const parsed = (await response.json());
|
|
75
|
+
if (!batched) {
|
|
76
|
+
return [toResult(Array.isArray(parsed) ? parsed[0] : parsed)];
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(parsed)) {
|
|
79
|
+
singleOnly.add(endpoint);
|
|
80
|
+
throw new ChunkTooLargeError("evm rpc: batch unsupported");
|
|
81
|
+
}
|
|
82
|
+
const byId = new Map(parsed.map(entry => [Number(entry.id), entry]));
|
|
83
|
+
return calls.map((_, id) => toResult(byId.get(id)));
|
|
84
|
+
};
|
|
85
|
+
const batcherFor = (endpoint) => {
|
|
86
|
+
const existing = batchers.get(endpoint);
|
|
87
|
+
if (existing)
|
|
88
|
+
return existing;
|
|
89
|
+
const batcher = new Batcher({
|
|
90
|
+
send: (calls, attempt) => send(endpoint, calls, attempt),
|
|
91
|
+
key: call => `${call.to.toLowerCase()}:${call.data.toLowerCase()}`,
|
|
92
|
+
maxWeight: options.maxCalls ?? 20,
|
|
93
|
+
concurrency: options.concurrency,
|
|
94
|
+
retries: options.retries,
|
|
95
|
+
baseDelayMs: options.baseDelayMs,
|
|
96
|
+
sleep: options.sleep
|
|
97
|
+
});
|
|
98
|
+
batchers.set(endpoint, batcher);
|
|
99
|
+
return batcher;
|
|
100
|
+
};
|
|
25
101
|
return {
|
|
26
102
|
async call(chainId, to, data, override) {
|
|
27
103
|
const endpoint = override ?? endpoints[chainId];
|
|
28
104
|
if (!endpoint)
|
|
29
105
|
throw new Error(`no evm endpoint for chain ${chainId}`);
|
|
30
|
-
|
|
31
|
-
method: "POST",
|
|
32
|
-
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
|
-
})
|
|
39
|
-
});
|
|
40
|
-
if (!response.ok)
|
|
41
|
-
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";
|
|
106
|
+
return batcherFor(endpoint).enqueue({ to, data });
|
|
46
107
|
}
|
|
47
108
|
};
|
|
48
109
|
}
|
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
131
|
-
return data
|
|
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
|
|
139
|
-
const container = page
|
|
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
|
|
159
|
-
return data
|
|
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
|
|
170
|
-
const container = page
|
|
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 ?? []) {
|