@crewhaus/tool-defi 0.7.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/README.md +270 -0
- package/dist/fixtures.d.ts +111 -0
- package/dist/fixtures.js +261 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +918 -0
- package/dist/lib/abi.d.ts +147 -0
- package/dist/lib/abi.js +245 -0
- package/dist/lib/batch.d.ts +21 -0
- package/dist/lib/batch.js +83 -0
- package/dist/lib/decimal.d.ts +103 -0
- package/dist/lib/decimal.js +246 -0
- package/dist/lib/oracle.d.ts +147 -0
- package/dist/lib/oracle.js +270 -0
- package/dist/lib/positions.d.ts +51 -0
- package/dist/lib/positions.js +301 -0
- package/dist/lib/quotes.d.ts +85 -0
- package/dist/lib/quotes.js +370 -0
- package/dist/lib/rpc.d.ts +171 -0
- package/dist/lib/rpc.js +600 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-defi — what is it worth right now, with provenance, and an
|
|
3
|
+
* explicit unpriced bucket.
|
|
4
|
+
*
|
|
5
|
+
* Four tools: a price from an offchain provider, a price from an onchain
|
|
6
|
+
* oracle, a lending or vault position normalised into one row, and a portfolio
|
|
7
|
+
* total assembled from the first three.
|
|
8
|
+
*
|
|
9
|
+
* Three rules run through all of it.
|
|
10
|
+
*
|
|
11
|
+
* **1. Every price says where it came from and how it was derived.** Which
|
|
12
|
+
* source, which round or which fixing date, and whether the number is a direct
|
|
13
|
+
* quote, an inversion of the pair the provider actually publishes, or a cross
|
|
14
|
+
* through an intermediate — and a cross names the intermediate and carries
|
|
15
|
+
* both legs. A price with no provenance is a number somebody will paste into a
|
|
16
|
+
* report, and nobody will be able to reproduce it a week later.
|
|
17
|
+
*
|
|
18
|
+
* **2. There is no boolean called `stale`.** A Chainlink aggregator updates on
|
|
19
|
+
* heartbeat OR on a deviation threshold, so elapsed time alone does not mean
|
|
20
|
+
* stale, and the real incomplete-round signal is `answeredInRound < roundId`.
|
|
21
|
+
* Pyth's freshness is a confidence interval over the price, which is a
|
|
22
|
+
* different quantity in different units. Each feed reports its own signal under
|
|
23
|
+
* its own name; collapsing them into one flag is how a wrong oracle verdict
|
|
24
|
+
* ships.
|
|
25
|
+
*
|
|
26
|
+
* **3. A portfolio total that silently drops what it could not price is wrong
|
|
27
|
+
* in the direction that looks good.** `PortfolioValuation` returns the total,
|
|
28
|
+
* the priced set and the unpriced set as three separate fields, and the
|
|
29
|
+
* unpriced set names every asset and why.
|
|
30
|
+
*
|
|
31
|
+
* **Nothing here signs or sends.** The RPC method set is `eth_call`,
|
|
32
|
+
* `eth_getBalance`, `eth_blockNumber` and `eth_getBlockByNumber`, gated first
|
|
33
|
+
* through `@crewhaus/chain-adapter-base`'s shared read-only chokepoint. No
|
|
34
|
+
* schema in this package has a field to pass a private key, a mnemonic or a
|
|
35
|
+
* keystore to, and `index.test.ts` asserts that over every schema rather than
|
|
36
|
+
* trusting this paragraph.
|
|
37
|
+
*
|
|
38
|
+
* Money is decimal strings and bigints end to end. A uint256 does not fit in a
|
|
39
|
+
* double, and eighteen decimals of precision silently becomes fifteen.
|
|
40
|
+
*/
|
|
41
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
42
|
+
import { CURRENCY_MINOR_UNITS } from "@crewhaus/tool-math";
|
|
43
|
+
import { z } from "zod";
|
|
44
|
+
import { SELECTORS, callNoArgs, callWithAddress, decodeWords, normalizeAddress } from "./lib/abi";
|
|
45
|
+
import { batchCalls } from "./lib/batch";
|
|
46
|
+
import { ZERO, add, compare, fixed, fromInteger, isPositive, multiply, parseFixed, ratioBps, roundToPlaces, toDecimalString, trim, } from "./lib/decimal";
|
|
47
|
+
import { readChainlink, readPyth } from "./lib/oracle";
|
|
48
|
+
import { DEFAULT_AAVE_BASE_DECIMALS, REFUSED_PROTOCOLS, SUPPORTED_PROTOCOLS, readPosition, } from "./lib/positions";
|
|
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";
|
|
51
|
+
/** The seams, and the config. Every test in this package drives the first two. */
|
|
52
|
+
export { _setFetch, _setClock, _resetDefiConfig, registerDefiConfig, getDefiConfig, DefiError, } from "./lib/rpc";
|
|
53
|
+
export { PROVIDER_ORIGINS } from "./lib/quotes";
|
|
54
|
+
export { SUPPORTED_PROTOCOLS, REFUSED_PROTOCOLS } from "./lib/positions";
|
|
55
|
+
const NETWORK_TOOL = {
|
|
56
|
+
readOnly: true,
|
|
57
|
+
concurrencySafe: true,
|
|
58
|
+
// Pillar 3 sink-side: these cross a network boundary and say so. The
|
|
59
|
+
// destinations are an operator's own endpoint and two constants, but "we
|
|
60
|
+
// only talk to the configured node" is not a reason to hide the fact that
|
|
61
|
+
// bytes leave the process.
|
|
62
|
+
scope: "external",
|
|
63
|
+
ioCapability: "network",
|
|
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}`);
|
|
72
|
+
const blockNumberField = z
|
|
73
|
+
.string()
|
|
74
|
+
.min(1)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("pin every read in this answer to one block; decimal or 0x hex, default the head block");
|
|
77
|
+
const ROUNDING_MODES = ["halfEven", "halfUp", "down"];
|
|
78
|
+
const roundingField = z
|
|
79
|
+
.enum(ROUNDING_MODES)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("halfEven (default, errors cancel over many rows), halfUp or down");
|
|
82
|
+
/** An explicit block, or the head. Decimal and hex are both common in the wild. */
|
|
83
|
+
function parseBlockNumber(raw) {
|
|
84
|
+
if (raw === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
const text = raw.trim();
|
|
87
|
+
if (!/^(0x[0-9a-fA-F]+|\d+)$/.test(text)) {
|
|
88
|
+
throw new DefiError(`blockNumber: "${raw}" is not a block number (decimal or 0x hex)`);
|
|
89
|
+
}
|
|
90
|
+
return BigInt(text);
|
|
91
|
+
}
|
|
92
|
+
function configOf(ctx) {
|
|
93
|
+
return resolveDefiConfig(ctx?.toolConfig);
|
|
94
|
+
}
|
|
95
|
+
function rpcOptions(ctx, timeoutMs, chainId) {
|
|
96
|
+
return {
|
|
97
|
+
...(ctx?.signal === undefined ? {} : { signal: ctx.signal }),
|
|
98
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
99
|
+
...(chainId === undefined ? {} : { chainId }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the tag every read in one answer shares.
|
|
104
|
+
*
|
|
105
|
+
* When no block is named this asks the node for its head and pins THAT, rather
|
|
106
|
+
* than sending "latest" with each call: two reads tagged "latest" seconds apart
|
|
107
|
+
* can land on different blocks, and the resulting valuation is a number that
|
|
108
|
+
* never existed at any height.
|
|
109
|
+
*/
|
|
110
|
+
async function pinBlock(endpoint, requested, options) {
|
|
111
|
+
if (requested !== undefined) {
|
|
112
|
+
return { blockNumber: requested, blockTag: blockTagOf(requested), pinnedByThisCall: false };
|
|
113
|
+
}
|
|
114
|
+
const head = await ethBlockNumber(endpoint, options);
|
|
115
|
+
if (!head.ok)
|
|
116
|
+
throw new DefiError(`could not pin a block: ${head.message}`);
|
|
117
|
+
return { blockNumber: head.value, blockTag: blockTagOf(head.value), pinnedByThisCall: true };
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// PriceQuote
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
export const priceQuote = buildTool({
|
|
123
|
+
name: "PriceQuote",
|
|
124
|
+
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
|
+
inputSchema: z
|
|
126
|
+
.object({
|
|
127
|
+
base: z.string().min(2).max(12).describe("the asset being priced, e.g. BTC or EUR"),
|
|
128
|
+
quote: z.string().min(2).max(12).describe("the asset to price it in, e.g. USD"),
|
|
129
|
+
at: z
|
|
130
|
+
.string()
|
|
131
|
+
.optional()
|
|
132
|
+
.describe("an ISO date (YYYY-MM-DD) for a historical rate; currencies only"),
|
|
133
|
+
providers: z
|
|
134
|
+
.array(z.enum(PROVIDER_NAMES))
|
|
135
|
+
.min(1)
|
|
136
|
+
.max(PROVIDER_NAMES.length)
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("provider order; default is the ECB first for currency pairs, Coinbase otherwise"),
|
|
139
|
+
via: z
|
|
140
|
+
.string()
|
|
141
|
+
.min(2)
|
|
142
|
+
.max(12)
|
|
143
|
+
.optional()
|
|
144
|
+
.describe("cross through this asset when no provider publishes the pair; default USD then EUR"),
|
|
145
|
+
allowCross: z
|
|
146
|
+
.boolean()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe("false refuses rather than composing two prices into one; default true"),
|
|
149
|
+
timeoutMs: timeoutField,
|
|
150
|
+
})
|
|
151
|
+
.strict(),
|
|
152
|
+
...NETWORK_TOOL,
|
|
153
|
+
execute: async (input, ctx) => {
|
|
154
|
+
const result = await quotePrice({
|
|
155
|
+
base: input.base,
|
|
156
|
+
quote: input.quote,
|
|
157
|
+
...(input.at === undefined ? {} : { at: input.at }),
|
|
158
|
+
...(input.providers === undefined ? {} : { providers: input.providers }),
|
|
159
|
+
...(input.via === undefined ? {} : { via: input.via }),
|
|
160
|
+
...(input.allowCross === undefined ? {} : { allowCross: input.allowCross }),
|
|
161
|
+
}, rpcOptions(ctx, input.timeoutMs));
|
|
162
|
+
return json(result.quote);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// OraclePriceRead
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
export const oraclePriceRead = buildTool({
|
|
169
|
+
name: "OraclePriceRead",
|
|
170
|
+
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
|
+
inputSchema: z
|
|
172
|
+
.object({
|
|
173
|
+
feed: z
|
|
174
|
+
.string()
|
|
175
|
+
.min(1)
|
|
176
|
+
.max(128)
|
|
177
|
+
.optional()
|
|
178
|
+
.describe("a feed pinned in the defi tool_config block, instead of chainId/kind/address"),
|
|
179
|
+
chainId: z.string().min(1).max(64).optional().describe("the chain whose RPC endpoint to use"),
|
|
180
|
+
kind: z.enum(["chainlink", "pyth"]).optional(),
|
|
181
|
+
address: z.string().min(1).optional().describe("the aggregator, or the Pyth contract"),
|
|
182
|
+
priceId: z.string().min(1).optional().describe("Pyth's 32-byte feed id; required for pyth"),
|
|
183
|
+
heartbeatSeconds: z
|
|
184
|
+
.number()
|
|
185
|
+
.int()
|
|
186
|
+
.positive()
|
|
187
|
+
.max(60 * 60 * 24 * 30)
|
|
188
|
+
.optional()
|
|
189
|
+
.describe("the feed's published heartbeat; without it beyondHeartbeat is null, not false"),
|
|
190
|
+
maxConfidenceBps: z
|
|
191
|
+
.number()
|
|
192
|
+
.int()
|
|
193
|
+
.min(0)
|
|
194
|
+
.max(10_000)
|
|
195
|
+
.optional()
|
|
196
|
+
.describe("Pyth only: the confidence-to-price bound to compare against"),
|
|
197
|
+
blockNumber: blockNumberField,
|
|
198
|
+
timeoutMs: timeoutField,
|
|
199
|
+
})
|
|
200
|
+
.strict()
|
|
201
|
+
.refine((v) => v.feed !== undefined ||
|
|
202
|
+
(v.chainId !== undefined && v.kind !== undefined && v.address !== undefined), {
|
|
203
|
+
message: "give either feed, or all of chainId, kind and address",
|
|
204
|
+
}),
|
|
205
|
+
...NETWORK_TOOL,
|
|
206
|
+
execute: async (input, ctx) => {
|
|
207
|
+
const config = configOf(ctx);
|
|
208
|
+
const pin = input.feed === undefined ? undefined : config.feeds.get(input.feed);
|
|
209
|
+
if (input.feed !== undefined && pin === undefined) {
|
|
210
|
+
const known = [...config.feeds.keys()].sort();
|
|
211
|
+
throw new DefiError(`no feed named "${input.feed}" is pinned — add it to the defi tool_config block under feeds${known.length === 0 ? " (none are pinned)" : `; pinned feeds: ${known.join(", ")}`}`);
|
|
212
|
+
}
|
|
213
|
+
const chainId = pin?.chainId ?? input.chainId;
|
|
214
|
+
const kind = pin?.kind ?? input.kind;
|
|
215
|
+
const address = pin?.address ?? input.address;
|
|
216
|
+
const priceId = input.priceId ?? pin?.priceId;
|
|
217
|
+
const heartbeatSeconds = input.heartbeatSeconds ?? pin?.heartbeatSeconds;
|
|
218
|
+
const endpoint = requireEndpoint(config, chainId);
|
|
219
|
+
const multicall3 = config.multicall.get(chainId);
|
|
220
|
+
const options = rpcOptions(ctx, input.timeoutMs, chainId);
|
|
221
|
+
// Everything that can be refused without asking anybody is refused here,
|
|
222
|
+
// before the block is pinned. A malformed address or a Pyth read with no
|
|
223
|
+
// price id should cost zero requests, and a test can only tell the two
|
|
224
|
+
// apart by counting what was dialled.
|
|
225
|
+
normalizeAddress(address, "the feed address");
|
|
226
|
+
if (kind === "pyth")
|
|
227
|
+
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,
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const STALENESS_NOTE = "there is no single 'stale' flag here on purpose: a Chainlink feed updates on heartbeat OR deviation, so beyondHeartbeat is a question and answeredInRoundBehindRoundId is the incomplete-round fact, while a Pyth feed's freshness is confidenceToPriceBps — a different quantity in different units";
|
|
258
|
+
function requirePriceId(priceId) {
|
|
259
|
+
if (priceId === undefined) {
|
|
260
|
+
throw new DefiError("a Pyth read needs priceId: one Pyth contract serves every feed, so without the id there is nothing to ask it for");
|
|
261
|
+
}
|
|
262
|
+
return priceId;
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// DefiPositionRead
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
export const defiPositionRead = buildTool({
|
|
268
|
+
name: "DefiPositionRead",
|
|
269
|
+
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
|
+
inputSchema: z
|
|
271
|
+
.object({
|
|
272
|
+
protocol: z
|
|
273
|
+
.string()
|
|
274
|
+
.min(1)
|
|
275
|
+
.max(64)
|
|
276
|
+
.describe(`one of ${SUPPORTED_PROTOCOLS.join(", ")}`),
|
|
277
|
+
chainId: z.string().min(1).max(64),
|
|
278
|
+
contract: z.string().min(1).describe("the Aave Pool, the Comet, or the ERC-4626 vault"),
|
|
279
|
+
account: z.string().min(1).describe("the position holder"),
|
|
280
|
+
baseCurrencyDecimals: z
|
|
281
|
+
.number()
|
|
282
|
+
.int()
|
|
283
|
+
.min(0)
|
|
284
|
+
.max(36)
|
|
285
|
+
.optional()
|
|
286
|
+
.describe(`Aave only: the market's BASE_CURRENCY_UNIT decimals; default ${DEFAULT_AAVE_BASE_DECIMALS}`),
|
|
287
|
+
collateralAssets: z
|
|
288
|
+
.array(z.string().min(1))
|
|
289
|
+
.max(32)
|
|
290
|
+
.optional()
|
|
291
|
+
.describe("Compound v3 only: collateral tokens to read balances for"),
|
|
292
|
+
blockNumber: blockNumberField,
|
|
293
|
+
timeoutMs: timeoutField,
|
|
294
|
+
})
|
|
295
|
+
.strict(),
|
|
296
|
+
...NETWORK_TOOL,
|
|
297
|
+
execute: async (input, ctx) => {
|
|
298
|
+
const protocol = input.protocol.trim().toLowerCase();
|
|
299
|
+
if (!SUPPORTED_PROTOCOLS.includes(protocol)) {
|
|
300
|
+
const refusal = REFUSED_PROTOCOLS[protocol];
|
|
301
|
+
throw new DefiError(refusal === undefined
|
|
302
|
+
? `"${input.protocol}" is not a protocol this reads; it reads ${SUPPORTED_PROTOCOLS.join(", ")}`
|
|
303
|
+
: `"${protocol}" is not read here. ${refusal}`);
|
|
304
|
+
}
|
|
305
|
+
const config = configOf(ctx);
|
|
306
|
+
const endpoint = requireEndpoint(config, input.chainId);
|
|
307
|
+
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,
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// PortfolioValuation
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
const priceSource = {
|
|
336
|
+
price: z.string().min(1).optional().describe("a price you already have, in the quote currency"),
|
|
337
|
+
feed: z
|
|
338
|
+
.string()
|
|
339
|
+
.min(1)
|
|
340
|
+
.max(128)
|
|
341
|
+
.optional()
|
|
342
|
+
.describe("a feed pinned in the defi tool_config block"),
|
|
343
|
+
oracle: z
|
|
344
|
+
.object({
|
|
345
|
+
kind: z.enum(["chainlink", "pyth"]),
|
|
346
|
+
address: z.string().min(1),
|
|
347
|
+
priceId: z.string().min(1).optional(),
|
|
348
|
+
heartbeatSeconds: z.number().int().positive().optional(),
|
|
349
|
+
maxConfidenceBps: z.number().int().min(0).max(10_000).optional(),
|
|
350
|
+
})
|
|
351
|
+
.strict()
|
|
352
|
+
.optional(),
|
|
353
|
+
quotePair: z
|
|
354
|
+
.object({
|
|
355
|
+
base: z.string().min(2).max(12),
|
|
356
|
+
via: z.string().min(2).max(12).optional(),
|
|
357
|
+
})
|
|
358
|
+
.strict()
|
|
359
|
+
.optional()
|
|
360
|
+
.describe("price this asset against the portfolio's quote currency through PriceQuote's providers"),
|
|
361
|
+
};
|
|
362
|
+
const holdingSchema = z
|
|
363
|
+
.object({
|
|
364
|
+
asset: z.string().min(1).max(64).describe("a label for this holding, e.g. WETH"),
|
|
365
|
+
amount: z.string().min(1).optional().describe("a decimal amount you already have"),
|
|
366
|
+
baseUnits: z.string().min(1).optional().describe("an integer amount in the token's base units"),
|
|
367
|
+
decimals: z.number().int().min(0).max(36).optional().describe("the token's decimals"),
|
|
368
|
+
token: z.string().min(1).optional().describe("an ERC-20 to read the wallet's balance of"),
|
|
369
|
+
native: z.boolean().optional().describe("read the wallet's native-coin balance instead"),
|
|
370
|
+
...priceSource,
|
|
371
|
+
})
|
|
372
|
+
.strict();
|
|
373
|
+
export const portfolioValuation = buildTool({
|
|
374
|
+
name: "PortfolioValuation",
|
|
375
|
+
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
|
+
inputSchema: z
|
|
377
|
+
.object({
|
|
378
|
+
quoteCurrency: z.string().min(2).max(12).describe("what to value everything in, e.g. USD"),
|
|
379
|
+
holdings: z.array(holdingSchema).min(1).max(256),
|
|
380
|
+
chainId: z
|
|
381
|
+
.string()
|
|
382
|
+
.min(1)
|
|
383
|
+
.max(64)
|
|
384
|
+
.optional()
|
|
385
|
+
.describe("required for any balance or oracle read"),
|
|
386
|
+
wallet: z.string().min(1).optional().describe("the address whose balances to read"),
|
|
387
|
+
blockNumber: blockNumberField,
|
|
388
|
+
at: z
|
|
389
|
+
.string()
|
|
390
|
+
.optional()
|
|
391
|
+
.describe("an ISO date for a historical valuation; requires blockNumber, and prices are asked for that date"),
|
|
392
|
+
places: z
|
|
393
|
+
.number()
|
|
394
|
+
.int()
|
|
395
|
+
.min(0)
|
|
396
|
+
.max(18)
|
|
397
|
+
.optional()
|
|
398
|
+
.describe("decimal places for the total; default is the quote currency's minor units, else 2"),
|
|
399
|
+
rounding: roundingField,
|
|
400
|
+
minValue: z
|
|
401
|
+
.string()
|
|
402
|
+
.min(1)
|
|
403
|
+
.optional()
|
|
404
|
+
.describe("rows worth less than this are summarised as dust — they stay in the total"),
|
|
405
|
+
timeoutMs: timeoutField,
|
|
406
|
+
})
|
|
407
|
+
.strict(),
|
|
408
|
+
...NETWORK_TOOL,
|
|
409
|
+
execute: async (input, ctx) => valuePortfolio(input, ctx),
|
|
410
|
+
});
|
|
411
|
+
async function valuePortfolio(input, ctx) {
|
|
412
|
+
const quoteCurrency = input.quoteCurrency.trim().toUpperCase();
|
|
413
|
+
const config = configOf(ctx);
|
|
414
|
+
const rounding = input.rounding ?? "halfEven";
|
|
415
|
+
const places = input.places ?? CURRENCY_MINOR_UNITS[quoteCurrency] ?? 2;
|
|
416
|
+
const requestedBlock = parseBlockNumber(input.blockNumber);
|
|
417
|
+
const usesQuoteProvider = input.holdings.some((h) => h.quotePair !== undefined);
|
|
418
|
+
// The corner the whole `at` path exists for: a current balance multiplied by
|
|
419
|
+
// a historical price is a number that looks plausible and means nothing, and
|
|
420
|
+
// so is a historical balance at today's price. The two are pinned together
|
|
421
|
+
// or the request is refused.
|
|
422
|
+
if (input.at !== undefined && requestedBlock === undefined) {
|
|
423
|
+
throw new DefiError("at was given without blockNumber: a historical valuation needs the balances and the prices resolved from the SAME instant, and resolving a date to a block is a different read (EvmBlockAtTimestamp)");
|
|
424
|
+
}
|
|
425
|
+
if (input.at === undefined && requestedBlock !== undefined && usesQuoteProvider) {
|
|
426
|
+
throw new DefiError("blockNumber was given for the balances but a holding is priced from a live provider quote: that mixes a historical balance with today's price. Give `at` as well, or price every holding from an oracle read at the same block, or from a price you supply.");
|
|
427
|
+
}
|
|
428
|
+
// The price-source shape is checked BEFORE anything is dialled, and a holding
|
|
429
|
+
// with a broken source is excluded from `needsChain`: otherwise a typo in one
|
|
430
|
+
// row's price source turns the whole call into a refusal for a missing
|
|
431
|
+
// chainId, which is a true statement about the wrong problem.
|
|
432
|
+
const sourceErrors = input.holdings.map((holding) => priceSourceError(holding));
|
|
433
|
+
const needsChain = input.holdings.some((h, index) => sourceErrors[index] === undefined &&
|
|
434
|
+
(h.token !== undefined ||
|
|
435
|
+
h.native === true ||
|
|
436
|
+
h.feed !== undefined ||
|
|
437
|
+
h.oracle !== undefined));
|
|
438
|
+
let endpoint;
|
|
439
|
+
let block;
|
|
440
|
+
const options = rpcOptions(ctx, input.timeoutMs, input.chainId);
|
|
441
|
+
if (needsChain) {
|
|
442
|
+
if (input.chainId === undefined) {
|
|
443
|
+
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");
|
|
444
|
+
}
|
|
445
|
+
endpoint = requireEndpoint(config, input.chainId);
|
|
446
|
+
block = await pinBlock(endpoint, requestedBlock, options);
|
|
447
|
+
}
|
|
448
|
+
const amounts = await resolveAmounts(input.holdings, {
|
|
449
|
+
sourceErrors,
|
|
450
|
+
endpoint,
|
|
451
|
+
blockTag: block?.blockTag,
|
|
452
|
+
wallet: input.wallet,
|
|
453
|
+
chainId: input.chainId,
|
|
454
|
+
multicall3: input.chainId === undefined ? undefined : config.multicall.get(input.chainId),
|
|
455
|
+
options,
|
|
456
|
+
});
|
|
457
|
+
const priced = [];
|
|
458
|
+
const unpriced = [];
|
|
459
|
+
const notes = [];
|
|
460
|
+
// Two running totals, on purpose. `total` is the sum of the ROUNDED rows, so
|
|
461
|
+
// the table a human reads adds up to the figure at the bottom of it;
|
|
462
|
+
// `exactSum` is the sum of the unrounded products, so the rounding the table
|
|
463
|
+
// cost is visible rather than argued about.
|
|
464
|
+
let total = ZERO;
|
|
465
|
+
let exactSum = ZERO;
|
|
466
|
+
for (let index = 0; index < input.holdings.length; index++) {
|
|
467
|
+
const holding = input.holdings[index];
|
|
468
|
+
const amount = amounts[index];
|
|
469
|
+
const sourceError = sourceErrors[index];
|
|
470
|
+
if (sourceError !== undefined) {
|
|
471
|
+
unpriced.push({
|
|
472
|
+
asset: holding.asset,
|
|
473
|
+
amount: amount.ok ? toDecimalString(trim(amount.value)) : null,
|
|
474
|
+
reason: sourceError,
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!amount.ok) {
|
|
479
|
+
unpriced.push({ asset: holding.asset, amount: null, reason: amount.reason });
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
let price;
|
|
483
|
+
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
|
+
});
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
// A holding that could not be priced is a ROW, not a thrown error: the
|
|
496
|
+
// other forty-nine assets still have values, and the whole point of the
|
|
497
|
+
// 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;
|
|
504
|
+
}
|
|
505
|
+
let exact;
|
|
506
|
+
let value;
|
|
507
|
+
try {
|
|
508
|
+
exact = multiply(amount.value, price.value);
|
|
509
|
+
value = roundToPlaces(exact, places, rounding);
|
|
510
|
+
}
|
|
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;
|
|
524
|
+
}
|
|
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
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const minValue = input.minValue === undefined ? undefined : parseFixed(input.minValue, "minValue");
|
|
538
|
+
let dustCount = 0;
|
|
539
|
+
let dustValue = ZERO;
|
|
540
|
+
for (const row of priced) {
|
|
541
|
+
const value = parseFixed(row.value, "a row value");
|
|
542
|
+
row.weightBps = isPositive(total) ? ratioBps(value, total) : 0;
|
|
543
|
+
if (minValue !== undefined && compare(value, minValue) < 0) {
|
|
544
|
+
row.belowMinValue = true;
|
|
545
|
+
dustCount++;
|
|
546
|
+
dustValue = add(dustValue, value);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (unpriced.length > 0) {
|
|
550
|
+
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
|
+
}
|
|
552
|
+
if (!isPositive(total) && priced.length > 0) {
|
|
553
|
+
// Zero or negative: a portfolio carrying more debt than collateral has a
|
|
554
|
+
// negative total, and a share OF a negative total is not a weight.
|
|
555
|
+
notes.push(`the priced total is ${toDecimalString(total)}, so weightBps is 0 on every row — a share of a total that is not positive is not a weight`);
|
|
556
|
+
}
|
|
557
|
+
if (block?.pinnedByThisCall === true) {
|
|
558
|
+
notes.push(`no block was given, so the head block ${block.blockNumber} was pinned and every read used it`);
|
|
559
|
+
}
|
|
560
|
+
return json({
|
|
561
|
+
quoteCurrency,
|
|
562
|
+
at: input.at ?? null,
|
|
563
|
+
blockNumber: block === undefined ? null : block.blockNumber.toString(),
|
|
564
|
+
blockTag: block?.blockTag ?? null,
|
|
565
|
+
total: {
|
|
566
|
+
// Rounded again for the empty case only: with no priced rows `total` is
|
|
567
|
+
// still scale 0, and a USD total that reads "0" where every other answer
|
|
568
|
+
// reads "0.00" is the kind of inconsistency a downstream parser trips on.
|
|
569
|
+
value: toDecimalString(roundToPlaces(total, places, rounding)),
|
|
570
|
+
places,
|
|
571
|
+
rounding,
|
|
572
|
+
// Said in the payload, not just in the docs: whoever reads this JSON
|
|
573
|
+
// three screens down is the person who would otherwise assume the total
|
|
574
|
+
// covers everything they passed in.
|
|
575
|
+
covers: "the priced holdings only; the unpriced ones are listed separately and are NOT in this figure",
|
|
576
|
+
},
|
|
577
|
+
exactTotal: toDecimalString(trim(exactSum)),
|
|
578
|
+
coverage: {
|
|
579
|
+
holdings: input.holdings.length,
|
|
580
|
+
pricedCount: priced.length,
|
|
581
|
+
unpricedCount: unpriced.length,
|
|
582
|
+
weightsNote: "weightBps is a share of the PRICED total, so the weights of an incomplete portfolio still sum to 10000",
|
|
583
|
+
},
|
|
584
|
+
priced,
|
|
585
|
+
unpriced,
|
|
586
|
+
dust: minValue === undefined
|
|
587
|
+
? null
|
|
588
|
+
: {
|
|
589
|
+
threshold: toDecimalString(minValue),
|
|
590
|
+
count: dustCount,
|
|
591
|
+
value: toDecimalString(roundToPlaces(dustValue, places, rounding)),
|
|
592
|
+
note: "dust rows are flagged, not dropped — they are still in the total, because a floor that removes value from a total is the same silent loss as an unpriced asset",
|
|
593
|
+
},
|
|
594
|
+
notes,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Whether a holding names exactly one price source.
|
|
599
|
+
*
|
|
600
|
+
* Zero sources and two sources are both answers, not crashes: the holding
|
|
601
|
+
* lands in the unpriced bucket with the reason, and the other forty-nine rows
|
|
602
|
+
* still get values. Two sources in particular must never be resolved by
|
|
603
|
+
* preferring one — that is a silent choice about which number a report shows.
|
|
604
|
+
*/
|
|
605
|
+
function priceSourceError(holding) {
|
|
606
|
+
const named = [
|
|
607
|
+
holding.price === undefined ? undefined : "price",
|
|
608
|
+
holding.feed === undefined ? undefined : "feed",
|
|
609
|
+
holding.oracle === undefined ? undefined : "oracle",
|
|
610
|
+
holding.quotePair === undefined ? undefined : "quotePair",
|
|
611
|
+
].filter((name) => name !== undefined);
|
|
612
|
+
if (named.length === 1)
|
|
613
|
+
return undefined;
|
|
614
|
+
if (named.length === 0) {
|
|
615
|
+
return `${holding.asset}: no price source — give price, feed, oracle or quotePair, or accept that it lands in the unpriced bucket`;
|
|
616
|
+
}
|
|
617
|
+
return `${holding.asset}: give exactly one of price, feed, oracle or quotePair; this names ${named.join(" and ")}`;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Whether a holding names more than one way to arrive at its amount.
|
|
621
|
+
*
|
|
622
|
+
* The same rule `priceSourceError` applies to prices, for the same reason: a
|
|
623
|
+
* holding carrying both `amount` and `token` was silently valued from
|
|
624
|
+
* `amount`, and one carrying both `token` and `native` was silently valued
|
|
625
|
+
* from the wallet's ETH — with nothing in the row saying which of the two the
|
|
626
|
+
* figure came from. "WETH: 7" reads as the token balance whichever one it is.
|
|
627
|
+
* Resolving that by hidden preference is the same silent choice about which
|
|
628
|
+
* number a report shows, one field along.
|
|
629
|
+
*
|
|
630
|
+
* Zero sources is NOT handled here: the messages further down name what is
|
|
631
|
+
* missing in the context of what the holding did ask for.
|
|
632
|
+
*/
|
|
633
|
+
function amountSourceError(holding) {
|
|
634
|
+
const named = [
|
|
635
|
+
holding.amount === undefined ? undefined : "amount",
|
|
636
|
+
holding.baseUnits === undefined ? undefined : "baseUnits",
|
|
637
|
+
holding.token === undefined ? undefined : "token",
|
|
638
|
+
holding.native === true ? "native" : undefined,
|
|
639
|
+
].filter((name) => name !== undefined);
|
|
640
|
+
if (named.length <= 1)
|
|
641
|
+
return undefined;
|
|
642
|
+
return `${holding.asset}: give exactly one of amount, baseUnits, token or native; this names ${named.join(" and ")}, and preferring one of them silently would hide which figure the row is`;
|
|
643
|
+
}
|
|
644
|
+
async function resolveAmounts(holdings, context) {
|
|
645
|
+
const out = holdings.map(() => ({ ok: false, reason: "not resolved" }));
|
|
646
|
+
// Every token read goes out as ONE batch at the pinned block, so fifty
|
|
647
|
+
// balances are fifty answers from one height rather than fifty races.
|
|
648
|
+
const tokenIndices = [];
|
|
649
|
+
const calls = [];
|
|
650
|
+
for (let index = 0; index < holdings.length; index++) {
|
|
651
|
+
const holding = holdings[index];
|
|
652
|
+
const ambiguous = amountSourceError(holding);
|
|
653
|
+
if (ambiguous !== undefined) {
|
|
654
|
+
// Before anything is dialled: an ambiguous holding must not cost a
|
|
655
|
+
// request to produce a row that is a refusal either way.
|
|
656
|
+
out[index] = { ok: false, reason: ambiguous };
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const sourceError = context.sourceErrors[index];
|
|
660
|
+
if (sourceError !== undefined) {
|
|
661
|
+
// No usable price source, so reading its balance would cost a request to
|
|
662
|
+
// produce a row that is unpriced either way — but an amount the caller
|
|
663
|
+
// already gave is kept, because "1,000,000 SPAM, unpriced" is a row
|
|
664
|
+
// somebody can act on and "unpriced" alone is not.
|
|
665
|
+
out[index] = givenAmount(holding) ?? { ok: false, reason: sourceError };
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const given = givenAmount(holding);
|
|
669
|
+
if (given !== undefined) {
|
|
670
|
+
out[index] = given;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (holding.native === true)
|
|
674
|
+
continue;
|
|
675
|
+
if (holding.token === undefined) {
|
|
676
|
+
out[index] = {
|
|
677
|
+
ok: false,
|
|
678
|
+
reason: `${holding.asset}: no amount — give amount, or baseUnits with decimals, or token (with a wallet), or native`,
|
|
679
|
+
};
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (context.wallet === undefined ||
|
|
683
|
+
context.endpoint === undefined ||
|
|
684
|
+
context.blockTag === undefined) {
|
|
685
|
+
out[index] = {
|
|
686
|
+
ok: false,
|
|
687
|
+
reason: `${holding.asset}: a token balance needs wallet and chainId`,
|
|
688
|
+
};
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const token = normalizeAddress(holding.token, `${holding.asset}.token`);
|
|
692
|
+
const wallet = normalizeAddress(context.wallet, "wallet");
|
|
693
|
+
tokenIndices.push(index);
|
|
694
|
+
calls.push({
|
|
695
|
+
to: token,
|
|
696
|
+
data: callWithAddress(SELECTORS.balanceOf, wallet, "wallet"),
|
|
697
|
+
label: `${holding.asset} balanceOf`,
|
|
698
|
+
});
|
|
699
|
+
calls.push({
|
|
700
|
+
to: token,
|
|
701
|
+
data: callNoArgs(SELECTORS.decimals),
|
|
702
|
+
label: `${holding.asset} decimals`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
if (calls.length > 0 && context.endpoint !== undefined && context.blockTag !== undefined) {
|
|
706
|
+
const results = await batchCalls(context.endpoint, calls, context.blockTag, {
|
|
707
|
+
...context.options,
|
|
708
|
+
...(context.multicall3 === undefined ? {} : { multicall3: context.multicall3 }),
|
|
709
|
+
});
|
|
710
|
+
tokenIndices.forEach((holdingIndex, slot) => {
|
|
711
|
+
const holding = holdings[holdingIndex];
|
|
712
|
+
const balanceRow = results[slot * 2];
|
|
713
|
+
const decimalsRow = results[slot * 2 + 1];
|
|
714
|
+
if (balanceRow === undefined || !balanceRow.ok) {
|
|
715
|
+
out[holdingIndex] = {
|
|
716
|
+
ok: false,
|
|
717
|
+
reason: `${holding.asset}: ${balanceRow?.reason ?? "no balance row returned"}`,
|
|
718
|
+
};
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
let decimals = holding.decimals;
|
|
722
|
+
if (decimalsRow?.ok) {
|
|
723
|
+
try {
|
|
724
|
+
decimals = Number(decodeWords(decimalsRow.data, 1, `${holding.asset} decimals`)[0]);
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
out[holdingIndex] = { ok: false, reason: `${holding.asset}: ${err.message}` };
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (decimals === undefined ||
|
|
732
|
+
!Number.isSafeInteger(decimals) ||
|
|
733
|
+
decimals < 0 ||
|
|
734
|
+
decimals > 36) {
|
|
735
|
+
// Defaulting to 18 here would be a factor of 10^12 on USDC. An
|
|
736
|
+
// unreadable decimals() is an unpriced row, not a guess.
|
|
737
|
+
out[holdingIndex] = {
|
|
738
|
+
ok: false,
|
|
739
|
+
reason: `${holding.asset}: the token's decimals() could not be read and none was supplied; a default would be a factor-of-a-trillion guess`,
|
|
740
|
+
};
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
const balance = decodeWords(balanceRow.data, 1, `${holding.asset} balanceOf`)[0];
|
|
745
|
+
out[holdingIndex] = { ok: true, value: fixed(balance, decimals) };
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
out[holdingIndex] = { ok: false, reason: `${holding.asset}: ${err.message}` };
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
for (let index = 0; index < holdings.length; index++) {
|
|
753
|
+
const holding = holdings[index];
|
|
754
|
+
if (holding.native !== true || context.sourceErrors[index] !== undefined)
|
|
755
|
+
continue;
|
|
756
|
+
if (amountSourceError(holding) !== undefined)
|
|
757
|
+
continue;
|
|
758
|
+
if (context.wallet === undefined ||
|
|
759
|
+
context.endpoint === undefined ||
|
|
760
|
+
context.blockTag === undefined) {
|
|
761
|
+
out[index] = {
|
|
762
|
+
ok: false,
|
|
763
|
+
reason: `${holding.asset}: a native balance needs wallet and chainId`,
|
|
764
|
+
};
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const balance = await ethGetBalance(context.endpoint, normalizeAddress(context.wallet, "wallet"), context.blockTag, context.options);
|
|
768
|
+
out[index] = balance.ok
|
|
769
|
+
? { ok: true, value: fixed(balance.value, holding.decimals ?? 18) }
|
|
770
|
+
: { ok: false, reason: `${holding.asset}: ${balance.message}` };
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
function givenAmount(holding) {
|
|
775
|
+
if (holding.amount !== undefined) {
|
|
776
|
+
try {
|
|
777
|
+
return { ok: true, value: parseFixed(holding.amount, `${holding.asset}.amount`) };
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
return { ok: false, reason: err.message };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (holding.baseUnits !== undefined) {
|
|
784
|
+
if (holding.decimals === undefined) {
|
|
785
|
+
return {
|
|
786
|
+
ok: false,
|
|
787
|
+
reason: `${holding.asset}: baseUnits needs decimals — an integer with no decimalisation is not an amount`,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
if (!/^-?\d+$/.test(holding.baseUnits.trim())) {
|
|
791
|
+
return {
|
|
792
|
+
ok: false,
|
|
793
|
+
reason: `${holding.asset}: baseUnits "${holding.baseUnits}" is not an integer`,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
return { ok: true, value: fixed(BigInt(holding.baseUnits.trim()), holding.decimals) };
|
|
797
|
+
}
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
async function priceFor(holding, context) {
|
|
801
|
+
// `priceSourceError` has already established that exactly one is set.
|
|
802
|
+
if (holding.price !== undefined) {
|
|
803
|
+
const value = parseFixed(holding.price, `${holding.asset}.price`);
|
|
804
|
+
if (value.unscaled < 0n) {
|
|
805
|
+
// A negative AMOUNT is a borrow and is allowed; a negative price is not a
|
|
806
|
+
// thing, and multiplied by a balance it subtracts from a total.
|
|
807
|
+
throw new DefiError(`${holding.asset}: a supplied price of ${toDecimalString(value)} is negative — a debt is a negative amount, not a negative price`);
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
value,
|
|
811
|
+
provenance: {
|
|
812
|
+
source: "given",
|
|
813
|
+
derivation: "supplied-by-caller",
|
|
814
|
+
quoteCurrency: context.quoteCurrency,
|
|
815
|
+
note: "this price was passed in, so its provenance is whoever passed it",
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
if (holding.feed !== undefined || holding.oracle !== undefined) {
|
|
820
|
+
if (context.endpoint === undefined ||
|
|
821
|
+
context.blockTag === undefined ||
|
|
822
|
+
context.chainId === undefined) {
|
|
823
|
+
throw new DefiError(`${holding.asset}: an oracle price needs chainId`);
|
|
824
|
+
}
|
|
825
|
+
const pin = holding.feed === undefined ? undefined : context.config.feeds.get(holding.feed);
|
|
826
|
+
if (holding.feed !== undefined && pin === undefined) {
|
|
827
|
+
throw new DefiError(`${holding.asset}: no feed named "${holding.feed}" is pinned in the defi tool_config block`);
|
|
828
|
+
}
|
|
829
|
+
const kind = pin?.kind ?? holding.oracle?.kind;
|
|
830
|
+
const address = pin?.address ?? holding.oracle?.address;
|
|
831
|
+
if (kind === undefined || address === undefined) {
|
|
832
|
+
throw new DefiError(`${holding.asset}: the oracle source needs a kind and an address`);
|
|
833
|
+
}
|
|
834
|
+
const multicall3 = context.config.multicall.get(context.chainId);
|
|
835
|
+
const heartbeatSeconds = pin?.heartbeatSeconds ?? holding.oracle?.heartbeatSeconds;
|
|
836
|
+
const reading = kind === "chainlink"
|
|
837
|
+
? await readChainlink({
|
|
838
|
+
chainId: context.chainId,
|
|
839
|
+
endpoint: context.endpoint,
|
|
840
|
+
address,
|
|
841
|
+
blockTag: context.blockTag,
|
|
842
|
+
...(heartbeatSeconds === undefined ? {} : { heartbeatSeconds }),
|
|
843
|
+
...(multicall3 === undefined ? {} : { multicall3 }),
|
|
844
|
+
}, context.options)
|
|
845
|
+
: await readPyth({
|
|
846
|
+
chainId: context.chainId,
|
|
847
|
+
endpoint: context.endpoint,
|
|
848
|
+
address,
|
|
849
|
+
priceId: requirePriceId(holding.oracle?.priceId ?? pin?.priceId),
|
|
850
|
+
blockTag: context.blockTag,
|
|
851
|
+
...(holding.oracle?.maxConfidenceBps === undefined
|
|
852
|
+
? {}
|
|
853
|
+
: { maxConfidenceBps: holding.oracle.maxConfidenceBps }),
|
|
854
|
+
...(multicall3 === undefined ? {} : { multicall3 }),
|
|
855
|
+
}, context.options);
|
|
856
|
+
assertUsablePrice(holding.asset, reading.reading);
|
|
857
|
+
return {
|
|
858
|
+
value: reading.value,
|
|
859
|
+
provenance: { ...reading.reading.provenance, signals: reading.reading.signals },
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
const pair = holding.quotePair;
|
|
863
|
+
const result = await quotePrice({
|
|
864
|
+
base: pair.base,
|
|
865
|
+
quote: context.quoteCurrency,
|
|
866
|
+
...(context.at === undefined ? {} : { at: context.at }),
|
|
867
|
+
...(pair.via === undefined ? {} : { via: pair.via }),
|
|
868
|
+
}, context.options);
|
|
869
|
+
return { value: result.value, provenance: quoteProvenance(result.quote) };
|
|
870
|
+
}
|
|
871
|
+
function quoteProvenance(quote) {
|
|
872
|
+
return {
|
|
873
|
+
source: quote.legs.map((leg) => leg.source).join("+"),
|
|
874
|
+
derivation: quote.derivation,
|
|
875
|
+
via: quote.via,
|
|
876
|
+
asOf: quote.asOf,
|
|
877
|
+
legs: quote.legs,
|
|
878
|
+
notes: quote.notes,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Refuse an oracle answer that is not a usable price.
|
|
883
|
+
*
|
|
884
|
+
* A non-positive answer or a round that was never completed is not a low
|
|
885
|
+
* price, it is a broken feed — and multiplying a balance by it produces a
|
|
886
|
+
* confident zero in a total. An incomplete round is a REFUSAL here rather than
|
|
887
|
+
* a caveat for the same reason: the row belongs in the unpriced bucket, which
|
|
888
|
+
* is visible, instead of in the total, which is not.
|
|
889
|
+
*/
|
|
890
|
+
function assertUsablePrice(asset, reading) {
|
|
891
|
+
if (reading.kind === "chainlink") {
|
|
892
|
+
if (reading.signals.updatedAtZero) {
|
|
893
|
+
throw new DefiError(`${asset}: the feed's round has never been completed (updatedAt is 0)`);
|
|
894
|
+
}
|
|
895
|
+
if (reading.signals.answerNotPositive) {
|
|
896
|
+
throw new DefiError(`${asset}: the feed answered ${reading.price}, which is a broken feed rather than a price`);
|
|
897
|
+
}
|
|
898
|
+
if (reading.signals.answeredInRoundBehindRoundId) {
|
|
899
|
+
throw new DefiError(`${asset}: answeredInRound (${reading.signals.answeredInRound}) is behind roundId (${reading.signals.roundId}) — the round is incomplete, so this price belongs to an earlier one`);
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (reading.signals.priceNotPositive) {
|
|
904
|
+
throw new DefiError(`${asset}: the Pyth feed answered ${reading.price}, which is not a price`);
|
|
905
|
+
}
|
|
906
|
+
if (reading.signals.beyondConfidenceBound === true) {
|
|
907
|
+
throw new DefiError(`${asset}: the Pyth confidence band is ${reading.signals.confidenceToPriceBps} bps of the price, past the ${reading.signals.maxConfidenceBps} bps bound`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
911
|
+
export const DEFI_TOOLS = Object.freeze([
|
|
912
|
+
defiPositionRead,
|
|
913
|
+
oraclePriceRead,
|
|
914
|
+
portfolioValuation,
|
|
915
|
+
priceQuote,
|
|
916
|
+
]);
|
|
917
|
+
/** Re-exported for callers assembling their own valuations from the same maths. */
|
|
918
|
+
export { add, compare, fixed, fromInteger, parseFixed, ratioBps, roundToPlaces, toDecimalString, } from "./lib/decimal";
|