@crewhaus/tool-defi 0.7.0 → 0.8.1

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/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
- const timeoutField = z
66
- .number()
67
- .int()
68
- .positive()
69
- .max(MAX_TIMEOUT_MS)
70
- .optional()
71
- .describe(`deadline for the whole call in ms; default ${DEFAULT_TIMEOUT_MS}`);
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 rpcOptions(ctx, timeoutMs, chainId) {
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
- ...(ctx?.signal === undefined ? {} : { signal: ctx.signal }),
98
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
99
- ...(chainId === undefined ? {} : { chainId }),
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
- }, rpcOptions(ctx, input.timeoutMs));
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
- const block = await pinBlock(endpoint, parseBlockNumber(input.blockNumber), options);
229
- const reading = kind === "chainlink"
230
- ? await readChainlink({
231
- chainId,
232
- endpoint,
233
- address,
234
- blockTag: block.blockTag,
235
- ...(heartbeatSeconds === undefined ? {} : { heartbeatSeconds }),
236
- ...(multicall3 === undefined ? {} : { multicall3 }),
237
- }, options)
238
- : await readPyth({
239
- chainId,
240
- endpoint,
241
- address,
242
- priceId: requirePriceId(priceId),
243
- blockTag: block.blockTag,
244
- ...(input.maxConfidenceBps === undefined
245
- ? {}
246
- : { maxConfidenceBps: input.maxConfidenceBps }),
247
- ...(multicall3 === undefined ? {} : { multicall3 }),
248
- }, options);
249
- return json({
250
- ...reading.reading,
251
- blockNumber: block.blockNumber.toString(),
252
- blockPinnedByThisCall: block.pinnedByThisCall,
253
- signalNote: STALENESS_NOTE,
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 options = rpcOptions(ctx, input.timeoutMs, input.chainId);
309
- const block = await pinBlock(endpoint, parseBlockNumber(input.blockNumber), options);
310
- const row = await readPosition({
311
- protocol: protocol,
312
- chainId: input.chainId,
313
- endpoint,
314
- contract: input.contract,
315
- account: input.account,
316
- blockTag: block.blockTag,
317
- ...(input.baseCurrencyDecimals === undefined
318
- ? {}
319
- : { baseCurrencyDecimals: input.baseCurrencyDecimals }),
320
- ...(input.collateralAssets === undefined
321
- ? {}
322
- : { collateralAssets: input.collateralAssets }),
323
- ...(multicall3 === undefined ? {} : { multicall3 }),
324
- }, options);
325
- return json({
326
- ...row,
327
- blockNumber: block.blockNumber.toString(),
328
- blockPinnedByThisCall: block.pinnedByThisCall,
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: timeoutField,
461
+ timeoutMs: timeoutFieldWithDefault(PORTFOLIO_DEFAULT_TIMEOUT_MS),
406
462
  })
407
463
  .strict(),
408
464
  ...NETWORK_TOOL,
409
- execute: async (input, ctx) => valuePortfolio(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 = rpcOptions(ctx, input.timeoutMs, input.chainId);
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
- const priced = [];
458
- const unpriced = [];
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
- unpriced.push({
472
- asset: holding.asset,
473
- amount: amount.ok ? toDecimalString(trim(amount.value)) : null,
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
- unpriced.push({ asset: holding.asset, amount: null, reason: amount.reason });
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
- price = await priceFor(holding, {
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.push({
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
- let exact;
506
- let value;
507
- try {
508
- exact = multiply(amount.value, price.value);
509
- value = roundToPlaces(exact, places, rounding);
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
- catch (err) {
512
- // The multiplication refuses past MAX_SCALE, and it used to do it out
513
- // here, outside the try above: one holding whose amount and price
514
- // between them ask for more decimal places than this carries threw out
515
- // of the whole call, so forty-nine priceable rows were lost to the
516
- // fiftieth. An arithmetic refusal is a row, exactly like a price
517
- // refusal is.
518
- unpriced.push({
519
- asset: holding.asset,
520
- amount: toDecimalString(trim(amount.value)),
521
- reason: `${holding.asset}: the amount and the price could not be multiplied — ${err.message}`,
522
- });
523
- continue;
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
- total = add(total, value);
526
- exactSum = add(exactSum, exact);
527
- priced.push({
528
- asset: holding.asset,
529
- amount: toDecimalString(trim(amount.value)),
530
- price: toDecimalString(trim(price.value)),
531
- value: toDecimalString(value),
532
- weightBps: 0,
533
- belowMinValue: false,
534
- provenance: price.provenance,
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 and that nothing on the receiving end enforces. `@crewhaus/tool-onchain`'s
100
- * decoder takes the low 20 bytes and ignores the rest, so this does too —
101
- * formatting the WHOLE word instead produces a 66-character string that is not
102
- * an address, which every downstream address check then rejects, so one
103
- * non-conforming contract takes out the read rather than the padding it
104
- * violated. The dirty padding is reported rather than swallowed: it is also
105
- * what calling the wrong function on the right contract looks like.
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 and that nothing on the receiving end enforces. `@crewhaus/tool-onchain`'s
159
- * decoder takes the low 20 bytes and ignores the rest, so this does too —
160
- * formatting the WHOLE word instead produces a 66-character string that is not
161
- * an address, which every downstream address check then rejects, so one
162
- * non-conforming contract takes out the read rather than the padding it
163
- * violated. The dirty padding is reported rather than swallowed: it is also
164
- * what calling the wrong function on the right contract looks like.
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);