@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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Exact decimal arithmetic on `{ unscaled, scale }` pairs.
3
+ *
4
+ * Everything this package values is ALREADY a scaled integer: a token balance
5
+ * is a uint256 with the token's decimals, a Chainlink answer is an int256 with
6
+ * the feed's `decimals()`, a Pyth price is an int64 with its own exponent. So
7
+ * `{ unscaled: balance, scale: 18 }` IS the balance, exactly, and no parsing
8
+ * step is needed to get there. `Number(balance)` would keep fifteen of its
9
+ * digits and quietly drop the rest, which for 18 decimals is the difference
10
+ * between a wei and a thousand of them.
11
+ *
12
+ * WHY THIS IS NOT IMPORTED FROM `@crewhaus/tool-math`: that package owns this
13
+ * monorepo's decimal kernel and this file mirrors it, but its entrypoint
14
+ * exports only its tools, `statsKernel` and the currency table — the kernel
15
+ * itself is module-private. Reaching past another package's entrypoint is the
16
+ * thing `@crewhaus/tool-table` names as the wrong move, and widening
17
+ * tool-math's surface mid-wave, while sibling packages are being written into
18
+ * the same checkout, is how one agent's edit lands on top of another's. So the
19
+ * arithmetic lives here and `lib.test.ts` pins every rounding case in it
20
+ * against tool-math's `Round` tool, through that public surface, on the ties
21
+ * and modes where two implementations diverge if they ever will.
22
+ */
23
+ export class DecimalError extends Error {
24
+ name = "DecimalError";
25
+ }
26
+ /** Scales past this are a bug, not a price. 18 decimals plus a cross is ~40. */
27
+ export const MAX_SCALE = 80;
28
+ const TEN = 10n;
29
+ const powCache = new Map();
30
+ export function pow10(n) {
31
+ if (!Number.isInteger(n) || n < 0 || n > MAX_SCALE * 4) {
32
+ throw new DecimalError(`10^${n} is outside the supported range`);
33
+ }
34
+ const cached = powCache.get(n);
35
+ if (cached !== undefined)
36
+ return cached;
37
+ const value = TEN ** BigInt(n);
38
+ powCache.set(n, value);
39
+ return value;
40
+ }
41
+ /** Build a Fixed, normalising a negative scale into the integer part. */
42
+ export function fixed(unscaled, scale) {
43
+ if (!Number.isInteger(scale))
44
+ throw new DecimalError(`scale ${scale} is not an integer`);
45
+ if (scale < 0) {
46
+ // A Pyth feed with a positive exponent lands here: price 5, expo 2 is 500,
47
+ // not "5 at scale -2". One representation means one comparison rule.
48
+ return { unscaled: unscaled * pow10(-scale), scale: 0 };
49
+ }
50
+ if (scale > MAX_SCALE) {
51
+ throw new DecimalError(`scale ${scale} is past the ${MAX_SCALE} limit for this package`);
52
+ }
53
+ return { unscaled, scale };
54
+ }
55
+ export const ZERO = { unscaled: 0n, scale: 0 };
56
+ /** An integer as a Fixed. */
57
+ export function fromInteger(value) {
58
+ return { unscaled: value, scale: 0 };
59
+ }
60
+ const PLAIN_DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
61
+ /**
62
+ * Parse a plain decimal string exactly.
63
+ *
64
+ * Exponent notation is refused rather than accepted: every literal this
65
+ * package reads comes from a price provider or a caller writing an amount, and
66
+ * `1e-7` in either place is far more likely to be a float that has already
67
+ * been through a double than a deliberate exponent.
68
+ */
69
+ export function parseFixed(text, what) {
70
+ const trimmed = text.trim();
71
+ if (trimmed === "" || !PLAIN_DECIMAL.test(trimmed)) {
72
+ throw new DecimalError(`${what}: "${text}" is not a plain decimal number (digits, an optional sign and at most one ".")`);
73
+ }
74
+ const negative = trimmed.startsWith("-");
75
+ const body = trimmed.replace(/^[+-]/, "");
76
+ const dot = body.indexOf(".");
77
+ const digits = dot < 0 ? body : body.slice(0, dot) + body.slice(dot + 1);
78
+ const scale = dot < 0 ? 0 : body.length - dot - 1;
79
+ if (scale > MAX_SCALE) {
80
+ throw new DecimalError(`${what}: "${text}" has ${scale} decimal places, past the ${MAX_SCALE} this package carries`);
81
+ }
82
+ const unscaled = BigInt(digits === "" ? "0" : digits);
83
+ return { unscaled: negative ? -unscaled : unscaled, scale };
84
+ }
85
+ /**
86
+ * Read a number that arrived inside a JSON document.
87
+ *
88
+ * `JSON.parse` turns a bare numeric literal into a double before anything here
89
+ * sees it, so a provider that publishes its rate as `0.85431` hands us the
90
+ * nearest double to that, not the digits. `String(value)` gives the shortest
91
+ * decimal that round-trips to the same double, which is exact for anything up
92
+ * to seventeen significant digits — an ECB reference rate has five. A provider
93
+ * that published a twenty-digit price as a bare number would lose the tail, so
94
+ * the caller is told which kind of literal a price came from rather than left
95
+ * to assume; see `PriceLiteralKind` below and the `literal` field on a quote.
96
+ */
97
+ export function fixedFromJson(value, what) {
98
+ if (typeof value === "string")
99
+ return { value: parseFixed(value, what), kind: "string" };
100
+ if (typeof value === "number") {
101
+ if (!Number.isFinite(value))
102
+ throw new DecimalError(`${what}: ${value} is not a finite number`);
103
+ const text = String(value);
104
+ if (!PLAIN_DECIMAL.test(text)) {
105
+ // A double large or small enough to stringify as "1e+21" or "5e-7".
106
+ throw new DecimalError(`${what}: the provider's JSON number ${text} is outside the range this reads without exponent notation`);
107
+ }
108
+ return { value: parseFixed(text, what), kind: "number" };
109
+ }
110
+ throw new DecimalError(`${what}: expected a number or a decimal string, got ${typeof value}`);
111
+ }
112
+ export function isZero(value) {
113
+ return value.unscaled === 0n;
114
+ }
115
+ export function isPositive(value) {
116
+ return value.unscaled > 0n;
117
+ }
118
+ export function negate(value) {
119
+ return { unscaled: -value.unscaled, scale: value.scale };
120
+ }
121
+ export function absolute(value) {
122
+ return value.unscaled < 0n ? negate(value) : value;
123
+ }
124
+ /** Align two values on the larger scale, exactly. */
125
+ function align(a, b) {
126
+ const scale = Math.max(a.scale, b.scale);
127
+ return {
128
+ a: a.unscaled * pow10(scale - a.scale),
129
+ b: b.unscaled * pow10(scale - b.scale),
130
+ scale,
131
+ };
132
+ }
133
+ export function add(a, b) {
134
+ const aligned = align(a, b);
135
+ return { unscaled: aligned.a + aligned.b, scale: aligned.scale };
136
+ }
137
+ export function subtract(a, b) {
138
+ return add(a, negate(b));
139
+ }
140
+ /** -1, 0 or 1. */
141
+ export function compare(a, b) {
142
+ const aligned = align(a, b);
143
+ return aligned.a < aligned.b ? -1 : aligned.a > aligned.b ? 1 : 0;
144
+ }
145
+ /**
146
+ * The exact product. Scales add, so nothing is rounded here — a balance at 18
147
+ * decimals times a price at 8 is a value at 26, and the rounding happens once,
148
+ * at the end, where a mode can be named for it.
149
+ */
150
+ export function multiply(a, b) {
151
+ const scale = a.scale + b.scale;
152
+ if (scale > MAX_SCALE) {
153
+ // Rather than round silently mid-chain, drop the product's tail once and
154
+ // say so is tempting; refusing is better, because a caller that reached
155
+ // scale 80 has multiplied something it did not mean to.
156
+ throw new DecimalError(`the product needs scale ${scale}, past the ${MAX_SCALE} limit — round an intermediate deliberately`);
157
+ }
158
+ return { unscaled: a.unscaled * b.unscaled, scale };
159
+ }
160
+ /** Integer division with the named mode. Shared by `divide` and `roundToPlaces`. */
161
+ export function divideRound(numerator, denominator, mode) {
162
+ if (denominator === 0n)
163
+ throw new DecimalError("division by zero");
164
+ const negative = numerator < 0n !== denominator < 0n;
165
+ const n = numerator < 0n ? -numerator : numerator;
166
+ const d = denominator < 0n ? -denominator : denominator;
167
+ const quotient = n / d;
168
+ const remainder = n % d;
169
+ if (remainder === 0n)
170
+ return negative ? -quotient : quotient;
171
+ let bump = false;
172
+ if (mode === "down") {
173
+ bump = false;
174
+ }
175
+ else {
176
+ const twice = remainder * 2n;
177
+ if (twice > d)
178
+ bump = true;
179
+ else if (twice < d)
180
+ bump = false;
181
+ // The tie. halfUp always goes away from zero; halfEven goes to the even
182
+ // quotient, which is the only mode whose errors cancel over many rows —
183
+ // and a portfolio total is many rows.
184
+ else
185
+ bump = mode === "halfUp" ? true : quotient % 2n !== 0n;
186
+ }
187
+ const magnitude = bump ? quotient + 1n : quotient;
188
+ return negative ? -magnitude : magnitude;
189
+ }
190
+ /** `a / b`, carried to `scale` decimal places with the named mode. */
191
+ export function divide(a, b, scale, mode) {
192
+ if (b.unscaled === 0n)
193
+ throw new DecimalError("division by zero");
194
+ if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
195
+ throw new DecimalError(`scale must be an integer from 0 to ${MAX_SCALE}, got ${scale}`);
196
+ }
197
+ // a/b = (a.unscaled * 10^(scale + b.scale)) / (b.unscaled * 10^a.scale)
198
+ const numerator = a.unscaled * pow10(scale + b.scale);
199
+ const denominator = b.unscaled * pow10(a.scale);
200
+ return { unscaled: divideRound(numerator, denominator, mode), scale };
201
+ }
202
+ /** Round to a fixed number of decimal places. */
203
+ export function roundToPlaces(value, places, mode) {
204
+ if (!Number.isInteger(places) || places < 0 || places > MAX_SCALE) {
205
+ throw new DecimalError(`places must be an integer from 0 to ${MAX_SCALE}, got ${places}`);
206
+ }
207
+ if (places >= value.scale) {
208
+ return { unscaled: value.unscaled * pow10(places - value.scale), scale: places };
209
+ }
210
+ return {
211
+ unscaled: divideRound(value.unscaled, pow10(value.scale - places), mode),
212
+ scale: places,
213
+ };
214
+ }
215
+ /** Drop trailing zeros without changing the value: 1.500 -> 1.5, 100 stays 100. */
216
+ export function trim(value) {
217
+ let { unscaled, scale } = value;
218
+ while (scale > 0 && unscaled % TEN === 0n) {
219
+ unscaled /= TEN;
220
+ scale--;
221
+ }
222
+ return { unscaled, scale };
223
+ }
224
+ /** Plain decimal notation, always. Never `1e-7`, never a float. */
225
+ export function toDecimalString(value) {
226
+ const negative = value.unscaled < 0n;
227
+ let digits = (negative ? -value.unscaled : value.unscaled).toString();
228
+ if (value.scale === 0)
229
+ return (negative ? "-" : "") + digits;
230
+ if (digits.length <= value.scale)
231
+ digits = digits.padStart(value.scale + 1, "0");
232
+ const cut = digits.length - value.scale;
233
+ return `${negative ? "-" : ""}${digits.slice(0, cut)}.${digits.slice(cut)}`;
234
+ }
235
+ /**
236
+ * A ratio in basis points, rounded down.
237
+ *
238
+ * Down, deliberately: every caller of this is reporting a share or a spread,
239
+ * and a share rounded up can make a set of weights sum past 10000, which reads
240
+ * as an arithmetic bug in the output rather than as the rounding it is.
241
+ */
242
+ export function ratioBps(part, whole) {
243
+ if (isZero(whole))
244
+ throw new DecimalError("the whole is zero, so there is no ratio");
245
+ return Number(toDecimalString(divide(multiply(part, fromInteger(10000n)), whole, 0, "down")));
246
+ }
@@ -0,0 +1,147 @@
1
+ import { type Fixed } from "./decimal";
2
+ import { type RpcOptions } from "./rpc";
3
+ /** Chainlink and Pyth. Everything else is refused by name rather than guessed at. */
4
+ export type OracleKind = "chainlink" | "pyth";
5
+ export type OracleProvenance = {
6
+ readonly source: OracleKind;
7
+ readonly chainId: string;
8
+ /** Scheme and host only — an RPC URL's path routinely carries an API key. */
9
+ readonly endpoint: string;
10
+ readonly contract: string;
11
+ readonly blockTag: string;
12
+ /** How the number was arrived at. An onchain feed read is never a cross. */
13
+ readonly derivation: "oracle-direct";
14
+ /**
15
+ * The instant the feed itself publishes for this price: a Chainlink round's
16
+ * `updatedAt`, or a Pyth price's `publishTime`. Never the time the response
17
+ * arrived — that would make every reading look fresh.
18
+ *
19
+ * A DECIMAL STRING, because it is a uint256 the contract chose: through
20
+ * `Number()` a word of 2^255 prints as `5.78960446186581e+76`, which is
21
+ * neither the value nor a timestamp, in a package whose whole point is that
22
+ * chain quantities do not go through a double.
23
+ */
24
+ readonly publishedAt: string;
25
+ readonly publishedAtSource: "chainlink.updatedAt" | "pyth.publishTime";
26
+ /** The round this answer belongs to. Chainlink only. */
27
+ readonly roundId?: string;
28
+ readonly priceId?: string;
29
+ };
30
+ export type ChainlinkSignals = {
31
+ /**
32
+ * `answeredInRound < roundId` — the round has opened but no answer has been
33
+ * carried into it. THIS is the incomplete-round signal, not elapsed time.
34
+ */
35
+ readonly answeredInRoundBehindRoundId: boolean;
36
+ readonly roundId: string;
37
+ readonly answeredInRound: string;
38
+ /** A round that has never been updated. `updatedAt === 0` on a fresh feed. */
39
+ readonly updatedAtZero: boolean;
40
+ /** A price feed answering zero or less is broken, not cheap. */
41
+ readonly answerNotPositive: boolean;
42
+ /**
43
+ * `now - updatedAt`, from the injected clock, or NULL when `updatedAt` is not
44
+ * a time this can subtract: a uint256 too large to be a unix second, or one
45
+ * far enough ahead of the clock that the feed is not describing the past.
46
+ * Null means unknown, and every consumer of it has to say so too.
47
+ */
48
+ readonly secondsSinceUpdate: number | null;
49
+ /**
50
+ * `secondsSinceUpdate > heartbeatSeconds`, or null when no heartbeat was
51
+ * supplied. Read it as a QUESTION: an aggregator updates on heartbeat or on
52
+ * a deviation threshold, so a quiet market legitimately sits past its
53
+ * heartbeat without anything being wrong.
54
+ *
55
+ * Also null when the age is unknown. `false` is a claim — "this feed is
56
+ * inside its heartbeat" — and a threshold that could not be evaluated must
57
+ * never be reported as one that held: a feed dated a year into the future
58
+ * otherwise passes every freshness gate in the building.
59
+ */
60
+ readonly beyondHeartbeat: boolean | null;
61
+ readonly heartbeatSeconds: number | null;
62
+ /**
63
+ * `updatedAt` is ahead of the clock by more than the tolerance below. A
64
+ * couple of seconds is ordinary block-timestamp skew; a week is a broken
65
+ * feed, a wrong proxy address, or a contract that answers whatever it likes.
66
+ */
67
+ readonly updatedAtInFuture: boolean;
68
+ /** `updatedAt` is not a plausible unix second, so no age could be taken from it. */
69
+ readonly updatedAtOutOfRange: boolean;
70
+ };
71
+ export type PythSignals = {
72
+ /**
73
+ * `confidence / |price|` in basis points. This is Pyth's own uncertainty
74
+ * measure and it is what "stale" means on a Pyth feed — a widening band is
75
+ * the publisher set disagreeing, which is a different event from an old
76
+ * timestamp and is not comparable to a Chainlink heartbeat.
77
+ */
78
+ readonly confidenceToPriceBps: number;
79
+ /** `confidenceToPriceBps > maxConfidenceBps`, or null when no bound was given. */
80
+ readonly beyondConfidenceBound: boolean | null;
81
+ readonly maxConfidenceBps: number | null;
82
+ readonly priceNotPositive: boolean;
83
+ /** As for Chainlink: null when `publishTime` is not a time this can subtract. */
84
+ readonly secondsSincePublish: number | null;
85
+ readonly publishTimeInFuture: boolean;
86
+ readonly publishTimeOutOfRange: boolean;
87
+ };
88
+ export type OracleReading = {
89
+ readonly kind: "chainlink";
90
+ readonly price: string;
91
+ readonly decimals: number;
92
+ /** The feed's own label, e.g. "ETH / USD" — the cheapest check that the pinned address is the feed you meant. */
93
+ readonly description: string | null;
94
+ readonly startedAt: string;
95
+ readonly updatedAt: string;
96
+ readonly signals: ChainlinkSignals;
97
+ readonly provenance: OracleProvenance;
98
+ readonly notes: ReadonlyArray<string>;
99
+ } | {
100
+ readonly kind: "pyth";
101
+ readonly price: string;
102
+ readonly confidence: string;
103
+ readonly exponent: number;
104
+ readonly publishTime: string;
105
+ readonly signals: PythSignals;
106
+ readonly provenance: OracleProvenance;
107
+ readonly notes: ReadonlyArray<string>;
108
+ };
109
+ /** The exact value behind `price`, for arithmetic that must not go through a string. */
110
+ export type OracleValue = {
111
+ readonly reading: OracleReading;
112
+ readonly value: Fixed;
113
+ };
114
+ export type ChainlinkRequest = {
115
+ readonly chainId: string;
116
+ readonly endpoint: string;
117
+ readonly address: string;
118
+ readonly blockTag: string;
119
+ readonly heartbeatSeconds?: number;
120
+ readonly multicall3?: string;
121
+ };
122
+ export type FeedAge = {
123
+ readonly secondsSince: number | null;
124
+ readonly inFuture: boolean;
125
+ readonly outOfRange: boolean;
126
+ readonly note: string | null;
127
+ };
128
+ /**
129
+ * The age of a contract-supplied timestamp, in bigint, or the reason there is
130
+ * no age.
131
+ *
132
+ * `Number(updatedAt)` was the original shape and it is the trap: a word of
133
+ * 2^255 becomes 5.8e76, `now - that` is a large negative number, and a
134
+ * negative age compares as comfortably inside every heartbeat there is.
135
+ */
136
+ export declare function feedAge(timestamp: bigint, what: string): FeedAge;
137
+ export declare function readChainlink(request: ChainlinkRequest, options?: RpcOptions): Promise<OracleValue>;
138
+ export type PythRequest = {
139
+ readonly chainId: string;
140
+ readonly endpoint: string;
141
+ readonly address: string;
142
+ readonly priceId: string;
143
+ readonly blockTag: string;
144
+ readonly maxConfidenceBps?: number;
145
+ readonly multicall3?: string;
146
+ };
147
+ export declare function readPyth(request: PythRequest, options?: RpcOptions): Promise<OracleValue>;
@@ -0,0 +1,270 @@
1
+ import { MAX_CONTRACT_TEXT_CHARS, SELECTORS, asSigned, callNoArgs, callWithBytes32, decodeString, decodeWords, normalizeAddress, normalizeBytes32, sanitizeContractText, } from "./abi";
2
+ /**
3
+ * Reading a price feed onchain, and saying what its own freshness signal says.
4
+ *
5
+ * THE POINT OF THIS FILE: "stale" is not one thing, and collapsing the two
6
+ * things it means is how a wrong oracle verdict ships.
7
+ *
8
+ * - **A Chainlink aggregator updates on heartbeat OR deviation.** A feed
9
+ * whose price has not moved legitimately looks old — an hour past a
10
+ * one-hour heartbeat is routine, not a halt. The signal that a round is
11
+ * genuinely incomplete is `answeredInRound < roundId`, which is a fact
12
+ * about the round rather than a fact about the clock. Checking only age
13
+ * produces false halts; checking only round completeness misses a feed
14
+ * that is frozen with a complete round behind it. So both are reported,
15
+ * under their own names, and the heartbeat comparison only happens when
16
+ * the caller supplies the heartbeat — it is a property of the feed's
17
+ * deployment, not something the aggregator will tell you, and a tool that
18
+ * guessed one would be inventing the threshold it then judges against.
19
+ *
20
+ * - **Pyth's freshness is a confidence interval over the price**, which is a
21
+ * different quantity in different units with a different failure mode. A
22
+ * Pyth price two seconds old with a confidence band 400 bps wide is worse
23
+ * than one a minute old with a band of 3 bps, and no single boolean can
24
+ * say that.
25
+ *
26
+ * So there is no `stale` field anywhere in this package. Each feed reports its
27
+ * own signal under its own name, and the caller decides.
28
+ */
29
+ import { batchCalls } from "./batch";
30
+ import { fixed, isPositive, ratioBps, toDecimalString, trim } from "./decimal";
31
+ import { DefiError, endpointLabel, nowSeconds } from "./rpc";
32
+ /** A Chainlink `decimals()` past this is not a feed. */
33
+ const MAX_FEED_DECIMALS = 36;
34
+ /**
35
+ * How far ahead of the clock a feed's own timestamp may sit and still be read
36
+ * as the past.
37
+ *
38
+ * A block timestamp is the proposer's, not ours, and reading at the head can
39
+ * legitimately land a couple of seconds ahead of a local clock. Past that, the
40
+ * feed is not describing something that has happened, and no age subtracted
41
+ * from it means anything.
42
+ */
43
+ const CLOCK_SKEW_TOLERANCE_SECONDS = 120;
44
+ /** Past this a uint256 is not a unix second — year 10000 is 253402300800. */
45
+ const MAX_PLAUSIBLE_UNIX_SECONDS = 253402300800n;
46
+ /**
47
+ * The age of a contract-supplied timestamp, in bigint, or the reason there is
48
+ * no age.
49
+ *
50
+ * `Number(updatedAt)` was the original shape and it is the trap: a word of
51
+ * 2^255 becomes 5.8e76, `now - that` is a large negative number, and a
52
+ * negative age compares as comfortably inside every heartbeat there is.
53
+ */
54
+ export function feedAge(timestamp, what) {
55
+ if (timestamp > MAX_PLAUSIBLE_UNIX_SECONDS) {
56
+ return {
57
+ secondsSince: null,
58
+ inFuture: true,
59
+ outOfRange: true,
60
+ note: `${what} is ${timestamp.toString()}, which is not a unix timestamp — no age can be taken from it, so its freshness is unknown rather than fine`,
61
+ };
62
+ }
63
+ const delta = BigInt(nowSeconds()) - timestamp;
64
+ const seconds = Number(delta);
65
+ if (delta < -BigInt(CLOCK_SKEW_TOLERANCE_SECONDS)) {
66
+ return {
67
+ secondsSince: seconds,
68
+ inFuture: true,
69
+ outOfRange: false,
70
+ note: `${what} is ${-seconds}s AHEAD of the clock, past the ${CLOCK_SKEW_TOLERANCE_SECONDS}s of block-timestamp skew this tolerates — a feed dated in the future has no age, and anything comparing it against a heartbeat would find it comfortably fresh`,
71
+ };
72
+ }
73
+ return { secondsSince: seconds, inFuture: false, outOfRange: false, note: null };
74
+ }
75
+ export async function readChainlink(request, options = {}) {
76
+ const contract = normalizeAddress(request.address, "the Chainlink feed address");
77
+ const calls = [
78
+ { to: contract, data: callNoArgs(SELECTORS.latestRoundData), label: "latestRoundData()" },
79
+ { to: contract, data: callNoArgs(SELECTORS.decimals), label: "decimals()" },
80
+ { to: contract, data: callNoArgs(SELECTORS.description), label: "description()" },
81
+ ];
82
+ const results = await batchCalls(request.endpoint, calls, request.blockTag, {
83
+ ...options,
84
+ chainId: request.chainId,
85
+ ...(request.multicall3 === undefined ? {} : { multicall3: request.multicall3 }),
86
+ });
87
+ const round = required(results[0], "latestRoundData()");
88
+ const decimalsData = required(results[1], "decimals()");
89
+ const [roundId, answerWord, startedAt, updatedAt, answeredInRound] = decodeWords(round, 5, "latestRoundData()");
90
+ const answer = asSigned(answerWord, 256);
91
+ const decimals = Number(decodeWords(decimalsData, 1, "decimals()")[0]);
92
+ if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > MAX_FEED_DECIMALS) {
93
+ throw new DefiError(`the feed at ${contract} answered decimals() with ${decimals}; a price feed has 0 to ${MAX_FEED_DECIMALS}`);
94
+ }
95
+ // `description()` is not on every aggregator ever deployed, so its failure is
96
+ // a missing label rather than a failed read — but the reason is kept, because
97
+ // "this address has no description()" is also what a wrong address looks like.
98
+ const notes = [];
99
+ const descriptionResult = results[2];
100
+ let description = null;
101
+ if (descriptionResult?.ok) {
102
+ try {
103
+ // Sanitised, because this is the one free-text field in the output and a
104
+ // contract's bytes are written by whoever deployed it — not by the caller
105
+ // who was handed the address. A real aggregator's label is nine
106
+ // characters; anything longer is using the result as a delivery route.
107
+ const decoded = sanitizeContractText(decodeString(descriptionResult.data, "description()"));
108
+ description = decoded.text;
109
+ if (decoded.truncated) {
110
+ notes.push(`description() was longer than ${MAX_CONTRACT_TEXT_CHARS} characters and is shown truncated — a real feed's label is "ETH / USD", so treat a long one as a reason to check the address rather than as a label`);
111
+ }
112
+ if (decoded.hadControlCharacters) {
113
+ notes.push("description() contained control characters, which are replaced here: returned bytes carrying newlines can impersonate the lines printed around them");
114
+ }
115
+ }
116
+ catch (err) {
117
+ notes.push(`description() did not decode as a string: ${err.message}`);
118
+ }
119
+ }
120
+ else if (descriptionResult !== undefined) {
121
+ notes.push(`description() could not be read (${descriptionResult.reason}) — the feed's own label is the cheapest check that this address is the pair you meant`);
122
+ }
123
+ const value = fixed(answer, decimals);
124
+ const age = feedAge(updatedAt, "the round's updatedAt");
125
+ if (age.note !== null)
126
+ notes.push(age.note);
127
+ const heartbeatSeconds = request.heartbeatSeconds ?? null;
128
+ if (answeredInRound < roundId) {
129
+ notes.push("answeredInRound is behind roundId: a round has opened that no answer has been carried into, so this price belongs to an earlier round");
130
+ }
131
+ if (updatedAt === 0n) {
132
+ notes.push("updatedAt is zero — this round has never been completed, and the answer means nothing");
133
+ }
134
+ if (answer <= 0n) {
135
+ notes.push("the feed answered zero or less, which is a broken feed rather than a low price");
136
+ }
137
+ return {
138
+ value,
139
+ reading: {
140
+ kind: "chainlink",
141
+ // Trimmed: the feed's scale is reported as `decimals`, so eight trailing
142
+ // zeros in the price would read as significance the feed did not claim.
143
+ price: toDecimalString(trim(value)),
144
+ decimals,
145
+ description,
146
+ startedAt: startedAt.toString(),
147
+ updatedAt: updatedAt.toString(),
148
+ signals: {
149
+ answeredInRoundBehindRoundId: answeredInRound < roundId,
150
+ roundId: roundId.toString(),
151
+ answeredInRound: answeredInRound.toString(),
152
+ updatedAtZero: updatedAt === 0n,
153
+ answerNotPositive: answer <= 0n,
154
+ secondsSinceUpdate: age.secondsSince,
155
+ // Three ways to be null, and `false` is never one of them: no
156
+ // heartbeat to compare against, no age to compare, or an age that runs
157
+ // the wrong way. Each is "this was not evaluated", which is a
158
+ // different answer from "it held".
159
+ beyondHeartbeat: heartbeatSeconds === null || age.secondsSince === null || age.inFuture
160
+ ? null
161
+ : age.secondsSince > heartbeatSeconds,
162
+ heartbeatSeconds,
163
+ updatedAtInFuture: age.inFuture,
164
+ updatedAtOutOfRange: age.outOfRange,
165
+ },
166
+ provenance: {
167
+ source: "chainlink",
168
+ chainId: request.chainId,
169
+ endpoint: endpointLabel(request.endpoint),
170
+ contract,
171
+ blockTag: request.blockTag,
172
+ derivation: "oracle-direct",
173
+ publishedAt: updatedAt.toString(),
174
+ publishedAtSource: "chainlink.updatedAt",
175
+ roundId: roundId.toString(),
176
+ },
177
+ notes,
178
+ },
179
+ };
180
+ }
181
+ export async function readPyth(request, options = {}) {
182
+ const contract = normalizeAddress(request.address, "the Pyth contract address");
183
+ const priceId = normalizeBytes32(request.priceId, "the Pyth price id");
184
+ const calls = [
185
+ {
186
+ to: contract,
187
+ data: callWithBytes32(SELECTORS.getPriceUnsafe, priceId, "the Pyth price id"),
188
+ label: "getPriceUnsafe(bytes32)",
189
+ },
190
+ ];
191
+ const results = await batchCalls(request.endpoint, calls, request.blockTag, {
192
+ ...options,
193
+ chainId: request.chainId,
194
+ ...(request.multicall3 === undefined ? {} : { multicall3: request.multicall3 }),
195
+ });
196
+ const data = required(results[0], "getPriceUnsafe(bytes32)");
197
+ // PythStructs.Price is (int64 price, uint64 conf, int32 expo, uint publishTime).
198
+ // Every member is static, so the struct is four words inline — no offset.
199
+ const [priceWord, confWord, expoWord, publishWord] = decodeWords(data, 4, "getPriceUnsafe(bytes32)");
200
+ const rawPrice = asSigned(priceWord, 64);
201
+ const confidence = asSigned(confWord, 64);
202
+ // `expo` is an int32 and is almost always NEGATIVE: -8 means the integer is
203
+ // scaled by 1e-8. Read unsigned it is about 4.29e9, and the price comes back
204
+ // as a number with four billion zeros after it.
205
+ const exponent = Number(asSigned(expoWord, 32));
206
+ if (!Number.isSafeInteger(exponent) || exponent < -60 || exponent > 60) {
207
+ throw new DefiError(`the Pyth feed answered an exponent of ${exponent}, outside the range this reads`);
208
+ }
209
+ const publishTime = publishWord;
210
+ const value = fixed(rawPrice, -exponent);
211
+ const confidenceValue = fixed(confidence, -exponent);
212
+ const notes = [];
213
+ let confidenceToPriceBps = 0;
214
+ if (isPositive(value)) {
215
+ confidenceToPriceBps = ratioBps(confidenceValue, value);
216
+ }
217
+ else {
218
+ notes.push("the Pyth feed answered a price of zero or less, so its confidence ratio is undefined");
219
+ }
220
+ const maxConfidenceBps = request.maxConfidenceBps ?? null;
221
+ if (maxConfidenceBps !== null && isPositive(value) && confidenceToPriceBps > maxConfidenceBps) {
222
+ notes.push(`the confidence band is ${confidenceToPriceBps} bps of the price, past the ${maxConfidenceBps} bps bound supplied — on Pyth this, not elapsed time, is the freshness signal`);
223
+ }
224
+ if (publishTime === 0n) {
225
+ notes.push("publishTime is zero — this price id has never been updated on this contract");
226
+ }
227
+ const age = feedAge(publishTime, "the price's publishTime");
228
+ if (age.note !== null)
229
+ notes.push(age.note);
230
+ return {
231
+ value,
232
+ reading: {
233
+ kind: "pyth",
234
+ price: toDecimalString(trim(value)),
235
+ confidence: toDecimalString(trim(confidenceValue)),
236
+ exponent,
237
+ publishTime: publishTime.toString(),
238
+ signals: {
239
+ confidenceToPriceBps,
240
+ beyondConfidenceBound: maxConfidenceBps === null || !isPositive(value)
241
+ ? null
242
+ : confidenceToPriceBps > maxConfidenceBps,
243
+ maxConfidenceBps,
244
+ priceNotPositive: !isPositive(value),
245
+ secondsSincePublish: age.secondsSince,
246
+ publishTimeInFuture: age.inFuture,
247
+ publishTimeOutOfRange: age.outOfRange,
248
+ },
249
+ provenance: {
250
+ source: "pyth",
251
+ chainId: request.chainId,
252
+ endpoint: endpointLabel(request.endpoint),
253
+ contract,
254
+ blockTag: request.blockTag,
255
+ derivation: "oracle-direct",
256
+ publishedAt: publishTime.toString(),
257
+ publishedAtSource: "pyth.publishTime",
258
+ priceId,
259
+ },
260
+ notes,
261
+ },
262
+ };
263
+ }
264
+ function required(outcome, label) {
265
+ if (outcome === undefined)
266
+ throw new DefiError(`the batch returned no row for ${label}`);
267
+ if (!outcome.ok)
268
+ throw new DefiError(outcome.reason);
269
+ return outcome.data;
270
+ }