@crewhaus/tool-defi 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +27 -4
- package/dist/index.d.ts +21 -0
- package/dist/index.js +283 -120
- package/dist/lib/abi.d.ts +27 -7
- package/dist/lib/abi.js +35 -7
- package/dist/lib/oracle.js +13 -2
- package/dist/lib/quotes.js +17 -5
- package/dist/lib/rpc.d.ts +54 -1
- package/dist/lib/rpc.js +129 -61
- package/package.json +14 -9
- package/dist/fixtures.d.ts +0 -111
- package/dist/fixtures.js +0 -261
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ import { ZERO, add, compare, fixed, fromInteger, isPositive, multiply, parseFixe
|
|
|
47
47
|
import { readChainlink, readPyth } from "./lib/oracle";
|
|
48
48
|
import { DEFAULT_AAVE_BASE_DECIMALS, REFUSED_PROTOCOLS, SUPPORTED_PROTOCOLS, readPosition, } from "./lib/positions";
|
|
49
49
|
import { PROVIDER_NAMES, quotePrice } from "./lib/quotes";
|
|
50
|
-
import { DEFAULT_TIMEOUT_MS, DefiError, MAX_TIMEOUT_MS, blockTagOf, ethBlockNumber, ethGetBalance, json, requireEndpoint, resolveDefiConfig, } from "./lib/rpc";
|
|
50
|
+
import { DEFAULT_TIMEOUT_MS, DefiError, MAX_TIMEOUT_MS, blockTagOf, ethBlockNumber, ethGetBalance, json, newProviderLedger, requireEndpoint, resolveDefiConfig, startDeadline, } from "./lib/rpc";
|
|
51
51
|
/** The seams, and the config. Every test in this package drives the first two. */
|
|
52
52
|
export { _setFetch, _setClock, _resetDefiConfig, registerDefiConfig, getDefiConfig, DefiError, } from "./lib/rpc";
|
|
53
53
|
export { PROVIDER_ORIGINS } from "./lib/quotes";
|
|
@@ -62,13 +62,43 @@ const NETWORK_TOOL = {
|
|
|
62
62
|
scope: "external",
|
|
63
63
|
ioCapability: "network",
|
|
64
64
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
/**
|
|
66
|
+
* PortfolioValuation's default deadline. It reads a balance batch, an oracle
|
|
67
|
+
* or a quote per holding, one after another, for up to 256 holdings, so it
|
|
68
|
+
* gets more time than a single read; every other tool here uses
|
|
69
|
+
* DEFAULT_TIMEOUT_MS.
|
|
70
|
+
*/
|
|
71
|
+
export const PORTFOLIO_DEFAULT_TIMEOUT_MS = 60_000;
|
|
72
|
+
/**
|
|
73
|
+
* How many requests one call may send to the public price providers. A single
|
|
74
|
+
* PriceQuote needs at most fifteen (direct, inverted, then crossed through two
|
|
75
|
+
* intermediates), so its budget is a backstop. PortfolioValuation's is four
|
|
76
|
+
* per holding priced from a provider — what an asset no provider lists costs
|
|
77
|
+
* in a fiat quote currency: direct, inverse, and both via USD — between
|
|
78
|
+
* PriceQuote's sixteen and {@link PORTFOLIO_PROVIDER_REQUESTS}; an answer
|
|
79
|
+
* already fetched in the same call is reused, not counted again. The
|
|
80
|
+
* requests go out in rounds, one per holding still unpriced, and only whole
|
|
81
|
+
* rounds run, so which holdings are priced does not depend on their order
|
|
82
|
+
* (see valuePortfolio and ProviderLedger.allowance).
|
|
83
|
+
*/
|
|
84
|
+
export const PRICE_QUOTE_PROVIDER_REQUESTS = 16;
|
|
85
|
+
export const PORTFOLIO_PROVIDER_REQUESTS = 512;
|
|
86
|
+
const PROVIDER_REQUESTS_PER_HOLDING = 4;
|
|
87
|
+
/** PortfolioValuation's provider budget for these holdings. */
|
|
88
|
+
function portfolioProviderRequests(holdings) {
|
|
89
|
+
const quoting = holdings.filter((h) => h.quotePair !== undefined).length;
|
|
90
|
+
return Math.min(PORTFOLIO_PROVIDER_REQUESTS, Math.max(PRICE_QUOTE_PROVIDER_REQUESTS, quoting * PROVIDER_REQUESTS_PER_HOLDING));
|
|
91
|
+
}
|
|
92
|
+
function timeoutFieldWithDefault(defaultMs) {
|
|
93
|
+
return z
|
|
94
|
+
.number()
|
|
95
|
+
.int()
|
|
96
|
+
.positive()
|
|
97
|
+
.max(MAX_TIMEOUT_MS)
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(`deadline for the whole call in ms, every request it makes included; default ${defaultMs}`);
|
|
100
|
+
}
|
|
101
|
+
const timeoutField = timeoutFieldWithDefault(DEFAULT_TIMEOUT_MS);
|
|
72
102
|
const blockNumberField = z
|
|
73
103
|
.string()
|
|
74
104
|
.min(1)
|
|
@@ -92,13 +122,29 @@ function parseBlockNumber(raw) {
|
|
|
92
122
|
function configOf(ctx) {
|
|
93
123
|
return resolveDefiConfig(ctx?.toolConfig);
|
|
94
124
|
}
|
|
95
|
-
function
|
|
125
|
+
function startCall(ctx, timeoutMs, extra = {}) {
|
|
126
|
+
const deadline = startDeadline(timeoutMs, ctx?.signal);
|
|
127
|
+
const providers = extra.providerRequests === undefined ? undefined : newProviderLedger(extra.providerRequests);
|
|
96
128
|
return {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
deadline,
|
|
130
|
+
options: {
|
|
131
|
+
signal: deadline.signal,
|
|
132
|
+
timeoutMs,
|
|
133
|
+
...(extra.chainId === undefined ? {} : { chainId: extra.chainId }),
|
|
134
|
+
...(providers === undefined ? {} : { providers }),
|
|
135
|
+
},
|
|
100
136
|
};
|
|
101
137
|
}
|
|
138
|
+
/** Run `work` under one call budget, and stop its timer however `work` ends. */
|
|
139
|
+
async function withCall(ctx, timeoutMs, extra, work) {
|
|
140
|
+
const call = startCall(ctx, timeoutMs, extra);
|
|
141
|
+
try {
|
|
142
|
+
return await work(call);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
call.deadline.cancel();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
102
148
|
/**
|
|
103
149
|
* Resolve the tag every read in one answer shares.
|
|
104
150
|
*
|
|
@@ -121,6 +167,7 @@ async function pinBlock(endpoint, requested, options) {
|
|
|
121
167
|
// ---------------------------------------------------------------------------
|
|
122
168
|
export const priceQuote = buildTool({
|
|
123
169
|
name: "PriceQuote",
|
|
170
|
+
operativeArgs: [],
|
|
124
171
|
description: "Price one asset in another from a public, unauthenticated provider — the ECB's daily euro reference rates for currencies, Coinbase spot for everything else — and return the price with its provenance. Use it whenever a number will be reported to somebody: the result says which source answered, which fixing date it carries, and whether the price is a direct quote, an inversion of the pair the provider actually publishes, or a cross through an intermediate asset (and through which one, with both legs attached). It refuses a historical CRYPTO quote rather than serving one from a spot endpoint that echoes no date, because a price that cannot be told apart from today's must not be labelled as last Tuesday's; a historical currency rate comes from the ECB fixing for that date and says which fixing it resolved to. Nothing here signs or sends anything.",
|
|
125
172
|
inputSchema: z
|
|
126
173
|
.object({
|
|
@@ -150,7 +197,7 @@ export const priceQuote = buildTool({
|
|
|
150
197
|
})
|
|
151
198
|
.strict(),
|
|
152
199
|
...NETWORK_TOOL,
|
|
153
|
-
execute: async (input, ctx) => {
|
|
200
|
+
execute: async (input, ctx) => withCall(ctx, input.timeoutMs ?? DEFAULT_TIMEOUT_MS, { providerRequests: PRICE_QUOTE_PROVIDER_REQUESTS }, async ({ options }) => {
|
|
154
201
|
const result = await quotePrice({
|
|
155
202
|
base: input.base,
|
|
156
203
|
quote: input.quote,
|
|
@@ -158,15 +205,19 @@ export const priceQuote = buildTool({
|
|
|
158
205
|
...(input.providers === undefined ? {} : { providers: input.providers }),
|
|
159
206
|
...(input.via === undefined ? {} : { via: input.via }),
|
|
160
207
|
...(input.allowCross === undefined ? {} : { allowCross: input.allowCross }),
|
|
161
|
-
},
|
|
208
|
+
}, options);
|
|
162
209
|
return json(result.quote);
|
|
163
|
-
},
|
|
210
|
+
}),
|
|
164
211
|
});
|
|
165
212
|
// ---------------------------------------------------------------------------
|
|
166
213
|
// OraclePriceRead
|
|
167
214
|
// ---------------------------------------------------------------------------
|
|
168
215
|
export const oraclePriceRead = buildTool({
|
|
169
216
|
name: "OraclePriceRead",
|
|
217
|
+
operativeArgs: [
|
|
218
|
+
{ field: "feed", kind: "id" },
|
|
219
|
+
{ field: "address", kind: "id", within: "chainId" },
|
|
220
|
+
],
|
|
170
221
|
description: "Read a Chainlink or Pyth price feed onchain at a pinned block and report the price with EACH FEED'S OWN freshness signal, under its own name. Use it instead of asking whether a feed is 'stale', because that word means two different things: a Chainlink aggregator updates on its heartbeat OR on a deviation threshold, so an unchanged price legitimately looks old and the real incomplete-round signal is answeredInRound being behind roundId — while Pyth's signal is the width of its confidence interval relative to the price, a different quantity entirely. Both are reported, plus the round data, the feed's own description and the publish time the feed itself states; there is deliberately no single boolean called stale. The heartbeat comparison only happens when you supply the heartbeat, because it is a property of the deployment that the aggregator will not tell you. This reads; it never signs or sends.",
|
|
171
222
|
inputSchema: z
|
|
172
223
|
.object({
|
|
@@ -217,7 +268,6 @@ export const oraclePriceRead = buildTool({
|
|
|
217
268
|
const heartbeatSeconds = input.heartbeatSeconds ?? pin?.heartbeatSeconds;
|
|
218
269
|
const endpoint = requireEndpoint(config, chainId);
|
|
219
270
|
const multicall3 = config.multicall.get(chainId);
|
|
220
|
-
const options = rpcOptions(ctx, input.timeoutMs, chainId);
|
|
221
271
|
// Everything that can be refused without asking anybody is refused here,
|
|
222
272
|
// before the block is pinned. A malformed address or a Pyth read with no
|
|
223
273
|
// price id should cost zero requests, and a test can only tell the two
|
|
@@ -225,32 +275,34 @@ export const oraclePriceRead = buildTool({
|
|
|
225
275
|
normalizeAddress(address, "the feed address");
|
|
226
276
|
if (kind === "pyth")
|
|
227
277
|
requirePriceId(priceId);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
278
|
+
return withCall(ctx, input.timeoutMs ?? DEFAULT_TIMEOUT_MS, { chainId }, async ({ options }) => {
|
|
279
|
+
const block = await pinBlock(endpoint, parseBlockNumber(input.blockNumber), options);
|
|
280
|
+
const reading = kind === "chainlink"
|
|
281
|
+
? await readChainlink({
|
|
282
|
+
chainId,
|
|
283
|
+
endpoint,
|
|
284
|
+
address,
|
|
285
|
+
blockTag: block.blockTag,
|
|
286
|
+
...(heartbeatSeconds === undefined ? {} : { heartbeatSeconds }),
|
|
287
|
+
...(multicall3 === undefined ? {} : { multicall3 }),
|
|
288
|
+
}, options)
|
|
289
|
+
: await readPyth({
|
|
290
|
+
chainId,
|
|
291
|
+
endpoint,
|
|
292
|
+
address,
|
|
293
|
+
priceId: requirePriceId(priceId),
|
|
294
|
+
blockTag: block.blockTag,
|
|
295
|
+
...(input.maxConfidenceBps === undefined
|
|
296
|
+
? {}
|
|
297
|
+
: { maxConfidenceBps: input.maxConfidenceBps }),
|
|
298
|
+
...(multicall3 === undefined ? {} : { multicall3 }),
|
|
299
|
+
}, options);
|
|
300
|
+
return json({
|
|
301
|
+
...reading.reading,
|
|
302
|
+
blockNumber: block.blockNumber.toString(),
|
|
303
|
+
blockPinnedByThisCall: block.pinnedByThisCall,
|
|
304
|
+
signalNote: STALENESS_NOTE,
|
|
305
|
+
});
|
|
254
306
|
});
|
|
255
307
|
},
|
|
256
308
|
});
|
|
@@ -266,6 +318,7 @@ function requirePriceId(priceId) {
|
|
|
266
318
|
// ---------------------------------------------------------------------------
|
|
267
319
|
export const defiPositionRead = buildTool({
|
|
268
320
|
name: "DefiPositionRead",
|
|
321
|
+
operativeArgs: [{ field: "contract", kind: "id", within: "chainId" }],
|
|
269
322
|
description: "Read one Aave v3, Compound v3 or ERC-4626 position at a pinned block and normalise it into one row, with every figure labelled with the basis it is in. Use it because each protocol answers in its own units and the conversion is where the wrong number gets made: Aave reports in its oracle's base currency and returns type(uint256).max as the health factor of a debt-free account, Compound v3 has one borrowable asset and publishes its own liquidation verdict, and an ERC-4626 share is worth convertToAssets(shares) — not totalAssets/totalSupply, which differs for any vault with a fee. It reports how far the collateral basket may fall before the health factor reaches 1 rather than a liquidation price, because a liquidation price for an aggregate position depends on which collateral you assume moves. A protocol it cannot read is refused with what reading it would take, never probed. It reads; it never signs or sends.",
|
|
270
323
|
inputSchema: z
|
|
271
324
|
.object({
|
|
@@ -305,27 +358,29 @@ export const defiPositionRead = buildTool({
|
|
|
305
358
|
const config = configOf(ctx);
|
|
306
359
|
const endpoint = requireEndpoint(config, input.chainId);
|
|
307
360
|
const multicall3 = config.multicall.get(input.chainId);
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
361
|
+
const budget = { chainId: input.chainId };
|
|
362
|
+
return withCall(ctx, input.timeoutMs ?? DEFAULT_TIMEOUT_MS, budget, async ({ options }) => {
|
|
363
|
+
const block = await pinBlock(endpoint, parseBlockNumber(input.blockNumber), options);
|
|
364
|
+
const row = await readPosition({
|
|
365
|
+
protocol: protocol,
|
|
366
|
+
chainId: input.chainId,
|
|
367
|
+
endpoint,
|
|
368
|
+
contract: input.contract,
|
|
369
|
+
account: input.account,
|
|
370
|
+
blockTag: block.blockTag,
|
|
371
|
+
...(input.baseCurrencyDecimals === undefined
|
|
372
|
+
? {}
|
|
373
|
+
: { baseCurrencyDecimals: input.baseCurrencyDecimals }),
|
|
374
|
+
...(input.collateralAssets === undefined
|
|
375
|
+
? {}
|
|
376
|
+
: { collateralAssets: input.collateralAssets }),
|
|
377
|
+
...(multicall3 === undefined ? {} : { multicall3 }),
|
|
378
|
+
}, options);
|
|
379
|
+
return json({
|
|
380
|
+
...row,
|
|
381
|
+
blockNumber: block.blockNumber.toString(),
|
|
382
|
+
blockPinnedByThisCall: block.pinnedByThisCall,
|
|
383
|
+
});
|
|
329
384
|
});
|
|
330
385
|
},
|
|
331
386
|
});
|
|
@@ -372,6 +427,7 @@ const holdingSchema = z
|
|
|
372
427
|
.strict();
|
|
373
428
|
export const portfolioValuation = buildTool({
|
|
374
429
|
name: "PortfolioValuation",
|
|
430
|
+
operativeArgs: [{ field: "wallet", kind: "id", within: "chainId" }],
|
|
375
431
|
description: "Value a set of holdings in one currency at one pinned block and return the total, the priced holdings and the UNPRICED ones as three separate fields. Use it for any figure that will be reported: the unpriced bucket names every asset that could not be priced and why, so a total is never quietly the sum of whatever happened to have a price — which is the failure mode that makes a treasury number wrong in the direction that looks good. Amounts can be given, or read as ERC-20 and native balances for a wallet; prices can be given, read from a pinned oracle feed, or quoted from a public provider, and each priced row carries the provenance of its own price. Weights are stated as a share of the PRICED total. A historical valuation must pin both the block and the date, because a current balance multiplied by a historical price is a plausible number that means nothing. It reads; it never signs or sends.",
|
|
376
432
|
inputSchema: z
|
|
377
433
|
.object({
|
|
@@ -402,13 +458,16 @@ export const portfolioValuation = buildTool({
|
|
|
402
458
|
.min(1)
|
|
403
459
|
.optional()
|
|
404
460
|
.describe("rows worth less than this are summarised as dust — they stay in the total"),
|
|
405
|
-
timeoutMs:
|
|
461
|
+
timeoutMs: timeoutFieldWithDefault(PORTFOLIO_DEFAULT_TIMEOUT_MS),
|
|
406
462
|
})
|
|
407
463
|
.strict(),
|
|
408
464
|
...NETWORK_TOOL,
|
|
409
|
-
execute: async (input, ctx) =>
|
|
465
|
+
execute: async (input, ctx) => withCall(ctx, input.timeoutMs ?? PORTFOLIO_DEFAULT_TIMEOUT_MS, {
|
|
466
|
+
...(input.chainId === undefined ? {} : { chainId: input.chainId }),
|
|
467
|
+
providerRequests: portfolioProviderRequests(input.holdings),
|
|
468
|
+
}, (call) => valuePortfolio(input, ctx, call)),
|
|
410
469
|
});
|
|
411
|
-
async function valuePortfolio(input, ctx) {
|
|
470
|
+
async function valuePortfolio(input, ctx, call) {
|
|
412
471
|
const quoteCurrency = input.quoteCurrency.trim().toUpperCase();
|
|
413
472
|
const config = configOf(ctx);
|
|
414
473
|
const rounding = input.rounding ?? "halfEven";
|
|
@@ -437,7 +496,7 @@ async function valuePortfolio(input, ctx) {
|
|
|
437
496
|
h.oracle !== undefined));
|
|
438
497
|
let endpoint;
|
|
439
498
|
let block;
|
|
440
|
-
const options =
|
|
499
|
+
const options = call.options;
|
|
441
500
|
if (needsChain) {
|
|
442
501
|
if (input.chainId === undefined) {
|
|
443
502
|
throw new DefiError("chainId is required: a holding asks for a balance or an oracle read, and neither can happen without a chain's endpoint");
|
|
@@ -454,8 +513,9 @@ async function valuePortfolio(input, ctx) {
|
|
|
454
513
|
multicall3: input.chainId === undefined ? undefined : config.multicall.get(input.chainId),
|
|
455
514
|
options,
|
|
456
515
|
});
|
|
457
|
-
|
|
458
|
-
|
|
516
|
+
// Each holding's row, by its index, so the table keeps the holdings' order
|
|
517
|
+
// whichever pass priced them.
|
|
518
|
+
const rows = [];
|
|
459
519
|
const notes = [];
|
|
460
520
|
// Two running totals, on purpose. `total` is the sum of the ROUNDED rows, so
|
|
461
521
|
// the table a human reads adds up to the figure at the bottom of it;
|
|
@@ -463,76 +523,173 @@ async function valuePortfolio(input, ctx) {
|
|
|
463
523
|
// cost is visible rather than argued about.
|
|
464
524
|
let total = ZERO;
|
|
465
525
|
let exactSum = ZERO;
|
|
526
|
+
let outOfTime = 0;
|
|
527
|
+
let budgetShort = 0;
|
|
528
|
+
const ledger = options.providers;
|
|
529
|
+
const context = {
|
|
530
|
+
quoteCurrency,
|
|
531
|
+
config,
|
|
532
|
+
endpoint,
|
|
533
|
+
chainId: input.chainId,
|
|
534
|
+
blockTag: block?.blockTag,
|
|
535
|
+
at: input.at,
|
|
536
|
+
options,
|
|
537
|
+
};
|
|
538
|
+
const unpricedRow = (holding, amount, reason) => ({
|
|
539
|
+
asset: holding.asset,
|
|
540
|
+
amount: amount === null ? null : toDecimalString(trim(amount)),
|
|
541
|
+
reason,
|
|
542
|
+
});
|
|
543
|
+
const outOfTimeRow = (holding, amount) => {
|
|
544
|
+
outOfTime++;
|
|
545
|
+
return unpricedRow(holding, amount, `${holding.asset}: not priced — ${ctx?.signal?.aborted === true
|
|
546
|
+
? "the call was cancelled first"
|
|
547
|
+
: `the call's timeoutMs of ${call.deadline.ms}ms elapsed first`}`);
|
|
548
|
+
};
|
|
549
|
+
/** A price found: the row, or an arithmetic refusal as a row. */
|
|
550
|
+
const valued = (holding, amount, price) => {
|
|
551
|
+
let exact;
|
|
552
|
+
let value;
|
|
553
|
+
try {
|
|
554
|
+
exact = multiply(amount, price.value);
|
|
555
|
+
value = roundToPlaces(exact, places, rounding);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
// The multiplication refuses past MAX_SCALE, and it used to do it out
|
|
559
|
+
// here, outside the try above: one holding whose amount and price
|
|
560
|
+
// between them ask for more decimal places than this carries threw out
|
|
561
|
+
// of the whole call, so forty-nine priceable rows were lost to the
|
|
562
|
+
// fiftieth. An arithmetic refusal is a row, exactly like a price
|
|
563
|
+
// refusal is.
|
|
564
|
+
return {
|
|
565
|
+
unpriced: unpricedRow(holding, amount, `${holding.asset}: the amount and the price could not be multiplied — ${err.message}`),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
total = add(total, value);
|
|
569
|
+
exactSum = add(exactSum, exact);
|
|
570
|
+
return {
|
|
571
|
+
priced: {
|
|
572
|
+
asset: holding.asset,
|
|
573
|
+
amount: toDecimalString(trim(amount)),
|
|
574
|
+
price: toDecimalString(trim(price.value)),
|
|
575
|
+
value: toDecimalString(value),
|
|
576
|
+
weightBps: 0,
|
|
577
|
+
belowMinValue: false,
|
|
578
|
+
provenance: price.provenance,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
};
|
|
582
|
+
// Holdings priced from a public provider, left for the rounds below.
|
|
583
|
+
let quoting = [];
|
|
466
584
|
for (let index = 0; index < input.holdings.length; index++) {
|
|
467
585
|
const holding = input.holdings[index];
|
|
468
586
|
const amount = amounts[index];
|
|
469
587
|
const sourceError = sourceErrors[index];
|
|
470
588
|
if (sourceError !== undefined) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
reason: sourceError,
|
|
475
|
-
});
|
|
589
|
+
rows[index] = {
|
|
590
|
+
unpriced: unpricedRow(holding, amount.ok ? amount.value : null, sourceError),
|
|
591
|
+
};
|
|
476
592
|
continue;
|
|
477
593
|
}
|
|
478
594
|
if (!amount.ok) {
|
|
479
|
-
|
|
595
|
+
rows[index] = { unpriced: unpricedRow(holding, null, amount.reason) };
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (call.deadline.signal.aborted && holding.price === undefined) {
|
|
599
|
+
// Past the deadline nothing more is asked of anybody: every holding
|
|
600
|
+
// left is a row that says so, rather than a request per holding that
|
|
601
|
+
// is refused one at a time.
|
|
602
|
+
rows[index] = { unpriced: outOfTimeRow(holding, amount.value) };
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (holding.quotePair !== undefined && ledger !== undefined) {
|
|
606
|
+
quoting.push(index);
|
|
480
607
|
continue;
|
|
481
608
|
}
|
|
482
|
-
let price;
|
|
483
609
|
try {
|
|
484
|
-
|
|
485
|
-
quoteCurrency,
|
|
486
|
-
config,
|
|
487
|
-
endpoint,
|
|
488
|
-
chainId: input.chainId,
|
|
489
|
-
blockTag: block?.blockTag,
|
|
490
|
-
at: input.at,
|
|
491
|
-
options,
|
|
492
|
-
});
|
|
610
|
+
rows[index] = valued(holding, amount.value, await priceFor(holding, context));
|
|
493
611
|
}
|
|
494
612
|
catch (err) {
|
|
495
613
|
// A holding that could not be priced is a ROW, not a thrown error: the
|
|
496
614
|
// other forty-nine assets still have values, and the whole point of the
|
|
497
615
|
// unpriced bucket is that it is visible rather than absent.
|
|
498
|
-
unpriced.
|
|
499
|
-
asset: holding.asset,
|
|
500
|
-
amount: toDecimalString(trim(amount.value)),
|
|
501
|
-
reason: err.message,
|
|
502
|
-
});
|
|
503
|
-
continue;
|
|
616
|
+
rows[index] = { unpriced: unpricedRow(holding, amount.value, err.message) };
|
|
504
617
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
618
|
+
}
|
|
619
|
+
// The provider budget goes out in ROUNDS: each round lets every holding
|
|
620
|
+
// still unpriced make one more request (an answer this call already has
|
|
621
|
+
// is free), and a round runs only when what is left covers every holding
|
|
622
|
+
// in it. So which holdings are priced does not depend on where they sit in
|
|
623
|
+
// the list: a run of assets no provider lists cannot spend the requests a
|
|
624
|
+
// holding after them needed, nor can one before them take a larger share.
|
|
625
|
+
// A holding stays in play while its last attempt was cut short by the
|
|
626
|
+
// round; one that failed having asked everything it wanted is final.
|
|
627
|
+
const lastReason = new Map();
|
|
628
|
+
while (quoting.length > 0 && ledger !== undefined) {
|
|
629
|
+
if (call.deadline.signal.aborted)
|
|
630
|
+
break;
|
|
631
|
+
// Also what ends the loop: every holding kept in play asked one new
|
|
632
|
+
// request in its round, so each round spends the budget down.
|
|
633
|
+
if (ledger.limit - ledger.made < quoting.length)
|
|
634
|
+
break;
|
|
635
|
+
const next = [];
|
|
636
|
+
for (const index of quoting) {
|
|
637
|
+
if (call.deadline.signal.aborted) {
|
|
638
|
+
// Out of time: left for the rows below that say so.
|
|
639
|
+
next.push(index);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const holding = input.holdings[index];
|
|
643
|
+
const amount = amounts[index].value;
|
|
644
|
+
ledger.allowance = 1;
|
|
645
|
+
ledger.halted = false;
|
|
646
|
+
const refusedBefore = ledger.refused;
|
|
647
|
+
try {
|
|
648
|
+
const price = await priceFor(holding, context);
|
|
649
|
+
rows[index] = valued(holding, amount, price);
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
// Refused a request, so cut short by its round rather than out of
|
|
653
|
+
// routes: it stays in play.
|
|
654
|
+
if (ledger.refused > refusedBefore && !call.deadline.signal.aborted) {
|
|
655
|
+
next.push(index);
|
|
656
|
+
lastReason.set(index, err.message);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
rows[index] = { unpriced: unpricedRow(holding, amount, err.message) };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
510
662
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
663
|
+
quoting = next;
|
|
664
|
+
}
|
|
665
|
+
if (ledger !== undefined) {
|
|
666
|
+
ledger.allowance = undefined;
|
|
667
|
+
ledger.halted = false;
|
|
668
|
+
}
|
|
669
|
+
for (const index of quoting) {
|
|
670
|
+
const holding = input.holdings[index];
|
|
671
|
+
const amount = amounts[index].value;
|
|
672
|
+
const reason = lastReason.get(index);
|
|
673
|
+
if (call.deadline.signal.aborted) {
|
|
674
|
+
rows[index] = { unpriced: outOfTimeRow(holding, amount) };
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
budgetShort++;
|
|
678
|
+
rows[index] = {
|
|
679
|
+
unpriced: unpricedRow(holding, amount, reason ??
|
|
680
|
+
`${holding.asset}: not priced — this call's ${ledger?.limit} price-provider requests could not give every holding still unpriced a round`),
|
|
681
|
+
};
|
|
524
682
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
});
|
|
683
|
+
}
|
|
684
|
+
const priced = [];
|
|
685
|
+
const unpriced = [];
|
|
686
|
+
for (const row of rows) {
|
|
687
|
+
if (row === undefined)
|
|
688
|
+
continue;
|
|
689
|
+
if ("priced" in row)
|
|
690
|
+
priced.push(row.priced);
|
|
691
|
+
else
|
|
692
|
+
unpriced.push(row.unpriced);
|
|
536
693
|
}
|
|
537
694
|
const minValue = input.minValue === undefined ? undefined : parseFixed(input.minValue, "minValue");
|
|
538
695
|
let dustCount = 0;
|
|
@@ -549,6 +706,12 @@ async function valuePortfolio(input, ctx) {
|
|
|
549
706
|
if (unpriced.length > 0) {
|
|
550
707
|
notes.push(`${unpriced.length} of ${input.holdings.length} holding(s) could not be priced and are NOT in the total — see unpriced[], which names each one and why`);
|
|
551
708
|
}
|
|
709
|
+
if (outOfTime > 0) {
|
|
710
|
+
notes.push(`${outOfTime} holding(s) were not priced because the call ran out of time; a larger timeoutMs (up to ${MAX_TIMEOUT_MS}) or fewer holdings per call would price them`);
|
|
711
|
+
}
|
|
712
|
+
if (budgetShort > 0) {
|
|
713
|
+
notes.push(`${budgetShort} unpriced holding(s) had a price route that was not asked: this call's ${ledger?.limit} price-provider requests go out one per holding per round, and ran out before another round could reach every holding still unpriced; fewer distinct assets per call would try them`);
|
|
714
|
+
}
|
|
552
715
|
if (!isPositive(total) && priced.length > 0) {
|
|
553
716
|
// Zero or negative: a portfolio carrying more debt than collateral has a
|
|
554
717
|
// negative total, and a share OF a negative total is not a weight.
|
package/dist/lib/abi.d.ts
CHANGED
|
@@ -92,17 +92,37 @@ export declare function decodeWords(data: string, count: number, what: string):
|
|
|
92
92
|
* built on it looks spectacularly solvent.
|
|
93
93
|
*/
|
|
94
94
|
export declare function asSigned(word: bigint, bits: number): bigint;
|
|
95
|
+
/**
|
|
96
|
+
* Read the low `bits` of a word as an UNSIGNED integer.
|
|
97
|
+
*
|
|
98
|
+
* The counterpart of {@link asSigned}, and a `uintN` field must never go
|
|
99
|
+
* through that one: Pyth's `conf` is a uint64, and read as an int64 every
|
|
100
|
+
* confidence from 2^63 up came back negative — a negative band is never wider
|
|
101
|
+
* than any bound, so the widest confidence a feed can state passed as the
|
|
102
|
+
* tightest.
|
|
103
|
+
*/
|
|
104
|
+
export declare function asUnsigned(word: bigint, bits: number): bigint;
|
|
105
|
+
/**
|
|
106
|
+
* Whether a word is the one an ABI encoder writes for an integer of `bits`
|
|
107
|
+
* width: zero above the value for an unsigned one, the sign extended for a
|
|
108
|
+
* signed one. A word that is not is still read — by its low bits, as every
|
|
109
|
+
* reader here does — but it is worth saying so, because it is also what a
|
|
110
|
+
* contract that is not the one the caller meant looks like when it happens
|
|
111
|
+
* to answer the selector.
|
|
112
|
+
*/
|
|
113
|
+
export declare function isCanonicalInt(word: bigint, bits: number, signed: boolean): boolean;
|
|
95
114
|
/**
|
|
96
115
|
* Read a 32-byte word as an `address`.
|
|
97
116
|
*
|
|
98
117
|
* The low 20 bytes are the address; the upper 12 are padding that the ABI says
|
|
99
|
-
* is zero
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* what calling the wrong function on the right contract looks
|
|
118
|
+
* is zero. `@crewhaus/tool-onchain`'s `AbiDecode` refuses a word whose padding
|
|
119
|
+
* is not, as Solidity's decoder does. This reads the low 20 bytes anyway and
|
|
120
|
+
* REPORTS the dirty padding, so one non-conforming contract costs a caveat on
|
|
121
|
+
* a row rather than the whole valuation — formatting the WHOLE word instead
|
|
122
|
+
* produces a 66-character string that is not an address, which every
|
|
123
|
+
* downstream address check then rejects. The flag is not a formality: dirty
|
|
124
|
+
* padding is also what calling the wrong function on the right contract looks
|
|
125
|
+
* like.
|
|
106
126
|
*/
|
|
107
127
|
export declare function addressFromWord(word: bigint): {
|
|
108
128
|
address: string;
|
package/dist/lib/abi.js
CHANGED
|
@@ -151,17 +151,45 @@ export function asSigned(word, bits) {
|
|
|
151
151
|
const masked = word & (modulus - 1n);
|
|
152
152
|
return masked >= modulus >> 1n ? masked - modulus : masked;
|
|
153
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Read the low `bits` of a word as an UNSIGNED integer.
|
|
156
|
+
*
|
|
157
|
+
* The counterpart of {@link asSigned}, and a `uintN` field must never go
|
|
158
|
+
* through that one: Pyth's `conf` is a uint64, and read as an int64 every
|
|
159
|
+
* confidence from 2^63 up came back negative — a negative band is never wider
|
|
160
|
+
* than any bound, so the widest confidence a feed can state passed as the
|
|
161
|
+
* tightest.
|
|
162
|
+
*/
|
|
163
|
+
export function asUnsigned(word, bits) {
|
|
164
|
+
if (!Number.isInteger(bits) || bits <= 0 || bits > 256) {
|
|
165
|
+
throw new AbiError(`asUnsigned: ${bits} is not a supported integer width`);
|
|
166
|
+
}
|
|
167
|
+
return word & ((1n << BigInt(bits)) - 1n);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether a word is the one an ABI encoder writes for an integer of `bits`
|
|
171
|
+
* width: zero above the value for an unsigned one, the sign extended for a
|
|
172
|
+
* signed one. A word that is not is still read — by its low bits, as every
|
|
173
|
+
* reader here does — but it is worth saying so, because it is also what a
|
|
174
|
+
* contract that is not the one the caller meant looks like when it happens
|
|
175
|
+
* to answer the selector.
|
|
176
|
+
*/
|
|
177
|
+
export function isCanonicalInt(word, bits, signed) {
|
|
178
|
+
const value = signed ? asSigned(word, bits) : asUnsigned(word, bits);
|
|
179
|
+
return BigInt.asUintN(256, value) === word;
|
|
180
|
+
}
|
|
154
181
|
/**
|
|
155
182
|
* Read a 32-byte word as an `address`.
|
|
156
183
|
*
|
|
157
184
|
* The low 20 bytes are the address; the upper 12 are padding that the ABI says
|
|
158
|
-
* is zero
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* what calling the wrong function on the right contract looks
|
|
185
|
+
* is zero. `@crewhaus/tool-onchain`'s `AbiDecode` refuses a word whose padding
|
|
186
|
+
* is not, as Solidity's decoder does. This reads the low 20 bytes anyway and
|
|
187
|
+
* REPORTS the dirty padding, so one non-conforming contract costs a caveat on
|
|
188
|
+
* a row rather than the whole valuation — formatting the WHOLE word instead
|
|
189
|
+
* produces a 66-character string that is not an address, which every
|
|
190
|
+
* downstream address check then rejects. The flag is not a formality: dirty
|
|
191
|
+
* padding is also what calling the wrong function on the right contract looks
|
|
192
|
+
* like.
|
|
165
193
|
*/
|
|
166
194
|
export function addressFromWord(word) {
|
|
167
195
|
const low = word & ((1n << 160n) - 1n);
|