@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,147 @@
1
+ /**
2
+ * The handful of ABI shapes this package reads, and nothing more.
3
+ *
4
+ * `@crewhaus/tool-onchain` owns the general ABI coder for this monorepo, but
5
+ * its entrypoint exports the coder as TOOLS (`AbiEncodeCall`, `AbiDecode`,
6
+ * `FunctionSelector`) rather than as functions, and this package does not
7
+ * reach past another package's entrypoint or widen it while sibling packages
8
+ * are being written into the same checkout. What is left is small and fixed:
9
+ * every call here is a view function with at most two static arguments, and
10
+ * every answer is a run of 32-byte words plus, in one case, a string.
11
+ *
12
+ * The selectors below are CONSTANTS, not computed — there is no Keccak in this
13
+ * package's dependency set. That is only safe because it is checked: `lib.test.ts`
14
+ * puts every one of them through tool-onchain's `FunctionSelector` tool, and
15
+ * puts every decoder in this file through its `AbiDecode`, so a wrong constant
16
+ * or a mis-sliced word fails the suite rather than reading the wrong storage
17
+ * slot in production.
18
+ */
19
+ export declare class AbiError extends Error {
20
+ readonly name = "AbiError";
21
+ }
22
+ /**
23
+ * Four-byte selectors, each with the canonical signature it was hashed over.
24
+ *
25
+ * Canonical matters: `balanceOf(address)` and `balanceOf(address owner)` hash
26
+ * the same because parameter names are not part of the signature, but `uint`
27
+ * and `uint256` do NOT — the first is not canonical and hashes differently.
28
+ */
29
+ export declare const SELECTORS: Readonly<{
30
+ /** `decimals()` — ERC-20, and a Chainlink aggregator's answer scale. */
31
+ readonly decimals: "0x313ce567";
32
+ /** `description()` — a Chainlink feed's own label, e.g. "ETH / USD". */
33
+ readonly description: "0x7284e416";
34
+ /** `latestRoundData()` — (uint80,int256,uint256,uint256,uint80). */
35
+ readonly latestRoundData: "0xfeaf968c";
36
+ /** `balanceOf(address)` — ERC-20, and an ERC-4626 share balance. */
37
+ readonly balanceOf: "0x70a08231";
38
+ /** `getPriceUnsafe(bytes32)` — Pyth: (int64,uint64,int32,uint256). */
39
+ readonly getPriceUnsafe: "0x96834ad3";
40
+ /** `getUserAccountData(address)` — Aave v3 Pool: six uint256. */
41
+ readonly getUserAccountData: "0xbf92857c";
42
+ /** `convertToAssets(uint256)` — ERC-4626. */
43
+ readonly convertToAssets: "0x07a2d13a";
44
+ /** `asset()` — ERC-4626's underlying token. */
45
+ readonly asset: "0x38d52e0f";
46
+ /** `borrowBalanceOf(address)` — Compound v3 Comet, in base-token units. */
47
+ readonly borrowBalanceOf: "0x374c49b4";
48
+ /** `baseToken()` — Compound v3 Comet's single borrowable asset. */
49
+ readonly baseToken: "0xc55dae63";
50
+ /** `collateralBalanceOf(address,address)` — Compound v3 Comet. */
51
+ readonly collateralBalanceOf: "0x5c2549ee";
52
+ /** `isLiquidatable(address)` — Compound v3 Comet's own verdict, not a derived one. */
53
+ readonly isLiquidatable: "0x042e02cf";
54
+ }>;
55
+ /**
56
+ * Shape-check an address and lowercase it.
57
+ *
58
+ * Shape only: verifying an EIP-55 checksum needs Keccak, which this package
59
+ * does not have. `AddressCheck` in `@crewhaus/tool-onchain` is the tool that
60
+ * catches a typo in a mixed-case address, and every tool description here
61
+ * points a caller at it — reading the wrong contract is not an error this can
62
+ * detect for itself, it is a wrong answer.
63
+ */
64
+ export declare function normalizeAddress(raw: string, what: string): string;
65
+ export declare function normalizeBytes32(raw: string, what: string): string;
66
+ /** Calldata for a view function that takes no arguments. */
67
+ export declare function callNoArgs(selector: string): string;
68
+ /** Calldata for one `address` argument: left-padded into a 32-byte word. */
69
+ export declare function callWithAddress(selector: string, address: string, what: string): string;
70
+ /** Calldata for two `address` arguments, in declaration order. */
71
+ export declare function callWithAddresses(selector: string, first: string, second: string, what: string): string;
72
+ /** Calldata for one `uint256` argument. */
73
+ export declare function callWithUint256(selector: string, value: bigint, what: string): string;
74
+ /** Calldata for one `bytes32` argument. */
75
+ export declare function callWithBytes32(selector: string, value: string, what: string): string;
76
+ /**
77
+ * Split return data into exactly `count` unsigned 32-byte words.
78
+ *
79
+ * Exactly, not at least: a contract that answered with fewer words than its
80
+ * signature promises did not answer the question, and an address with no code
81
+ * answers `0x` — which zero-pads into a perfectly plausible price of zero if
82
+ * anyone lets it. `@crewhaus/tool-onchain`'s decoder takes the same line.
83
+ */
84
+ export declare function decodeWords(data: string, count: number, what: string): bigint[];
85
+ /**
86
+ * Reinterpret an unsigned word as a signed integer of `bits` width.
87
+ *
88
+ * A Chainlink `answer` is an int256 and a Pyth `expo` is an int32, and both
89
+ * are genuinely negative in the wild: `expo` is almost always negative, and a
90
+ * feed reporting a negative answer is the signal that something is wrong with
91
+ * it. Read unsigned, that same feed reports about 1.15e77 and the position
92
+ * built on it looks spectacularly solvent.
93
+ */
94
+ export declare function asSigned(word: bigint, bits: number): bigint;
95
+ /**
96
+ * Read a 32-byte word as an `address`.
97
+ *
98
+ * 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.
106
+ */
107
+ export declare function addressFromWord(word: bigint): {
108
+ address: string;
109
+ paddingDirty: boolean;
110
+ };
111
+ /** A contract-supplied label longer than this is not a label. "ETH / USD" is nine. */
112
+ export declare const MAX_CONTRACT_TEXT_CHARS = 128;
113
+ /**
114
+ * Make a contract-supplied string safe to put in a result.
115
+ *
116
+ * Every string in this package's output that did not come from the caller came
117
+ * from a contract, and a contract's bytes are written by whoever deployed it —
118
+ * which, for a feed address a caller was handed, is not the caller. A real
119
+ * aggregator's `description()` is nine characters; a long one is not a label,
120
+ * it is somebody using the one free-text field here as a way into the reader's
121
+ * context. Control characters go the same way: a newline in returned bytes
122
+ * lets them impersonate the lines printed around them.
123
+ *
124
+ * Truncation is REPORTED, never silent — a label quietly cut at 128 characters
125
+ * is a different label, and "this address is the pair you meant" is exactly the
126
+ * check a shortened one would pass by accident.
127
+ */
128
+ export declare function sanitizeContractText(raw: string, maxChars?: number): {
129
+ text: string;
130
+ truncated: boolean;
131
+ hadControlCharacters: boolean;
132
+ };
133
+ /**
134
+ * Decode return data holding one dynamic `string`.
135
+ *
136
+ * Head word is a byte OFFSET from the start of the data, not an index; the
137
+ * word at that offset is the byte length, and the bytes follow it padded up to
138
+ * a word boundary. Treating the offset as a length is the classic misread, and
139
+ * it produces a plausible short string rather than an error.
140
+ *
141
+ * `maxBytes` is a refusal, not a truncation: a node may return up to
142
+ * MAX_RESPONSE_BYTES, so without it a contract can hand back a megabyte of
143
+ * text that this would decode in full and carry into somebody's context window.
144
+ */
145
+ export declare function decodeString(data: string, what: string, maxBytes?: number): string;
146
+ /** The largest uint256, which several protocols use to mean "unbounded". */
147
+ export declare const UINT256_MAX: bigint;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The handful of ABI shapes this package reads, and nothing more.
3
+ *
4
+ * `@crewhaus/tool-onchain` owns the general ABI coder for this monorepo, but
5
+ * its entrypoint exports the coder as TOOLS (`AbiEncodeCall`, `AbiDecode`,
6
+ * `FunctionSelector`) rather than as functions, and this package does not
7
+ * reach past another package's entrypoint or widen it while sibling packages
8
+ * are being written into the same checkout. What is left is small and fixed:
9
+ * every call here is a view function with at most two static arguments, and
10
+ * every answer is a run of 32-byte words plus, in one case, a string.
11
+ *
12
+ * The selectors below are CONSTANTS, not computed — there is no Keccak in this
13
+ * package's dependency set. That is only safe because it is checked: `lib.test.ts`
14
+ * puts every one of them through tool-onchain's `FunctionSelector` tool, and
15
+ * puts every decoder in this file through its `AbiDecode`, so a wrong constant
16
+ * or a mis-sliced word fails the suite rather than reading the wrong storage
17
+ * slot in production.
18
+ */
19
+ export class AbiError extends Error {
20
+ name = "AbiError";
21
+ }
22
+ /**
23
+ * Four-byte selectors, each with the canonical signature it was hashed over.
24
+ *
25
+ * Canonical matters: `balanceOf(address)` and `balanceOf(address owner)` hash
26
+ * the same because parameter names are not part of the signature, but `uint`
27
+ * and `uint256` do NOT — the first is not canonical and hashes differently.
28
+ */
29
+ export const SELECTORS = Object.freeze({
30
+ /** `decimals()` — ERC-20, and a Chainlink aggregator's answer scale. */
31
+ decimals: "0x313ce567",
32
+ /** `description()` — a Chainlink feed's own label, e.g. "ETH / USD". */
33
+ description: "0x7284e416",
34
+ /** `latestRoundData()` — (uint80,int256,uint256,uint256,uint80). */
35
+ latestRoundData: "0xfeaf968c",
36
+ /** `balanceOf(address)` — ERC-20, and an ERC-4626 share balance. */
37
+ balanceOf: "0x70a08231",
38
+ /** `getPriceUnsafe(bytes32)` — Pyth: (int64,uint64,int32,uint256). */
39
+ getPriceUnsafe: "0x96834ad3",
40
+ /** `getUserAccountData(address)` — Aave v3 Pool: six uint256. */
41
+ getUserAccountData: "0xbf92857c",
42
+ /** `convertToAssets(uint256)` — ERC-4626. */
43
+ convertToAssets: "0x07a2d13a",
44
+ /** `asset()` — ERC-4626's underlying token. */
45
+ asset: "0x38d52e0f",
46
+ /** `borrowBalanceOf(address)` — Compound v3 Comet, in base-token units. */
47
+ borrowBalanceOf: "0x374c49b4",
48
+ /** `baseToken()` — Compound v3 Comet's single borrowable asset. */
49
+ baseToken: "0xc55dae63",
50
+ /** `collateralBalanceOf(address,address)` — Compound v3 Comet. */
51
+ collateralBalanceOf: "0x5c2549ee",
52
+ /** `isLiquidatable(address)` — Compound v3 Comet's own verdict, not a derived one. */
53
+ isLiquidatable: "0x042e02cf",
54
+ });
55
+ const HEX_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
56
+ const HEX_BYTES32 = /^0x[0-9a-fA-F]{64}$/;
57
+ const HEX_DATA = /^0x([0-9a-fA-F]{2})*$/;
58
+ /**
59
+ * Shape-check an address and lowercase it.
60
+ *
61
+ * Shape only: verifying an EIP-55 checksum needs Keccak, which this package
62
+ * does not have. `AddressCheck` in `@crewhaus/tool-onchain` is the tool that
63
+ * catches a typo in a mixed-case address, and every tool description here
64
+ * points a caller at it — reading the wrong contract is not an error this can
65
+ * detect for itself, it is a wrong answer.
66
+ */
67
+ export function normalizeAddress(raw, what) {
68
+ const text = raw.trim();
69
+ if (!HEX_ADDRESS.test(text)) {
70
+ throw new AbiError(`${what}: "${raw}" is not an address (0x followed by 40 hex characters) — run AddressCheck on it first`);
71
+ }
72
+ return text.toLowerCase();
73
+ }
74
+ export function normalizeBytes32(raw, what) {
75
+ const text = raw.trim();
76
+ if (!HEX_BYTES32.test(text)) {
77
+ throw new AbiError(`${what}: "${raw}" is not 0x followed by 64 hex characters`);
78
+ }
79
+ return text.toLowerCase();
80
+ }
81
+ /** Calldata for a view function that takes no arguments. */
82
+ export function callNoArgs(selector) {
83
+ return selector;
84
+ }
85
+ /** Calldata for one `address` argument: left-padded into a 32-byte word. */
86
+ export function callWithAddress(selector, address, what) {
87
+ return selector + normalizeAddress(address, what).slice(2).padStart(64, "0");
88
+ }
89
+ /** Calldata for two `address` arguments, in declaration order. */
90
+ export function callWithAddresses(selector, first, second, what) {
91
+ return (selector +
92
+ normalizeAddress(first, `${what} (first argument)`).slice(2).padStart(64, "0") +
93
+ normalizeAddress(second, `${what} (second argument)`).slice(2).padStart(64, "0"));
94
+ }
95
+ /** Calldata for one `uint256` argument. */
96
+ export function callWithUint256(selector, value, what) {
97
+ if (value < 0n)
98
+ throw new AbiError(`${what}: a uint256 argument must not be negative`);
99
+ const hex = value.toString(16);
100
+ if (hex.length > 64)
101
+ throw new AbiError(`${what}: ${value} does not fit in a uint256`);
102
+ return selector + hex.padStart(64, "0");
103
+ }
104
+ /** Calldata for one `bytes32` argument. */
105
+ export function callWithBytes32(selector, value, what) {
106
+ return selector + normalizeBytes32(value, what).slice(2);
107
+ }
108
+ function hexBody(data, what) {
109
+ const text = data.trim();
110
+ if (!HEX_DATA.test(text)) {
111
+ throw new AbiError(`${what}: expected 0x followed by an even number of hex characters`);
112
+ }
113
+ return text.slice(2);
114
+ }
115
+ /**
116
+ * Split return data into exactly `count` unsigned 32-byte words.
117
+ *
118
+ * Exactly, not at least: a contract that answered with fewer words than its
119
+ * signature promises did not answer the question, and an address with no code
120
+ * answers `0x` — which zero-pads into a perfectly plausible price of zero if
121
+ * anyone lets it. `@crewhaus/tool-onchain`'s decoder takes the same line.
122
+ */
123
+ export function decodeWords(data, count, what) {
124
+ const body = hexBody(data, what);
125
+ if (body.length === 0) {
126
+ throw new AbiError(`${what}: the call returned no data — the address has no code, or the function does not exist on it`);
127
+ }
128
+ if (body.length !== count * 64) {
129
+ throw new AbiError(`${what}: expected ${count} 32-byte word(s) (${count * 32} bytes), got ${body.length / 2}`);
130
+ }
131
+ const words = [];
132
+ for (let i = 0; i < count; i++) {
133
+ words.push(BigInt(`0x${body.slice(i * 64, (i + 1) * 64)}`));
134
+ }
135
+ return words;
136
+ }
137
+ /**
138
+ * Reinterpret an unsigned word as a signed integer of `bits` width.
139
+ *
140
+ * A Chainlink `answer` is an int256 and a Pyth `expo` is an int32, and both
141
+ * are genuinely negative in the wild: `expo` is almost always negative, and a
142
+ * feed reporting a negative answer is the signal that something is wrong with
143
+ * it. Read unsigned, that same feed reports about 1.15e77 and the position
144
+ * built on it looks spectacularly solvent.
145
+ */
146
+ export function asSigned(word, bits) {
147
+ if (!Number.isInteger(bits) || bits <= 0 || bits > 256) {
148
+ throw new AbiError(`asSigned: ${bits} is not a supported integer width`);
149
+ }
150
+ const modulus = 1n << BigInt(bits);
151
+ const masked = word & (modulus - 1n);
152
+ return masked >= modulus >> 1n ? masked - modulus : masked;
153
+ }
154
+ /**
155
+ * Read a 32-byte word as an `address`.
156
+ *
157
+ * 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.
165
+ */
166
+ export function addressFromWord(word) {
167
+ const low = word & ((1n << 160n) - 1n);
168
+ return {
169
+ address: `0x${low.toString(16).padStart(40, "0")}`,
170
+ paddingDirty: word !== low,
171
+ };
172
+ }
173
+ /** A contract-supplied label longer than this is not a label. "ETH / USD" is nine. */
174
+ export const MAX_CONTRACT_TEXT_CHARS = 128;
175
+ /**
176
+ * Make a contract-supplied string safe to put in a result.
177
+ *
178
+ * Every string in this package's output that did not come from the caller came
179
+ * from a contract, and a contract's bytes are written by whoever deployed it —
180
+ * which, for a feed address a caller was handed, is not the caller. A real
181
+ * aggregator's `description()` is nine characters; a long one is not a label,
182
+ * it is somebody using the one free-text field here as a way into the reader's
183
+ * context. Control characters go the same way: a newline in returned bytes
184
+ * lets them impersonate the lines printed around them.
185
+ *
186
+ * Truncation is REPORTED, never silent — a label quietly cut at 128 characters
187
+ * is a different label, and "this address is the pair you meant" is exactly the
188
+ * check a shortened one would pass by accident.
189
+ */
190
+ export function sanitizeContractText(raw, maxChars = MAX_CONTRACT_TEXT_CHARS) {
191
+ let hadControlCharacters = false;
192
+ let out = "";
193
+ for (const character of raw) {
194
+ const code = character.codePointAt(0) ?? 0;
195
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
196
+ hadControlCharacters = true;
197
+ out += "\uFFFD";
198
+ continue;
199
+ }
200
+ out += character;
201
+ }
202
+ const truncated = [...out].length > maxChars;
203
+ return {
204
+ text: truncated ? `${[...out].slice(0, maxChars).join("")}\u2026` : out,
205
+ truncated,
206
+ hadControlCharacters,
207
+ };
208
+ }
209
+ /**
210
+ * Decode return data holding one dynamic `string`.
211
+ *
212
+ * Head word is a byte OFFSET from the start of the data, not an index; the
213
+ * word at that offset is the byte length, and the bytes follow it padded up to
214
+ * a word boundary. Treating the offset as a length is the classic misread, and
215
+ * it produces a plausible short string rather than an error.
216
+ *
217
+ * `maxBytes` is a refusal, not a truncation: a node may return up to
218
+ * MAX_RESPONSE_BYTES, so without it a contract can hand back a megabyte of
219
+ * text that this would decode in full and carry into somebody's context window.
220
+ */
221
+ export function decodeString(data, what, maxBytes = 1024) {
222
+ const body = hexBody(data, what);
223
+ if (body.length < 128) {
224
+ throw new AbiError(`${what}: a returned string needs at least two words, got ${body.length / 2} bytes`);
225
+ }
226
+ const offset = Number(BigInt(`0x${body.slice(0, 64)}`));
227
+ if (!Number.isSafeInteger(offset) || offset % 32 !== 0 || offset * 2 + 64 > body.length) {
228
+ throw new AbiError(`${what}: the string's offset word (${offset}) does not point inside the data`);
229
+ }
230
+ const lengthAt = offset * 2;
231
+ const length = Number(BigInt(`0x${body.slice(lengthAt, lengthAt + 64)}`));
232
+ if (!Number.isSafeInteger(length) || lengthAt + 64 + length * 2 > body.length) {
233
+ throw new AbiError(`${what}: the string claims ${length} bytes, past the end of the data`);
234
+ }
235
+ if (length > maxBytes) {
236
+ throw new AbiError(`${what}: the string is ${length} bytes, past the ${maxBytes} this reads from a contract — a label that long is not a label`);
237
+ }
238
+ const bytes = body.slice(lengthAt + 64, lengthAt + 64 + length * 2);
239
+ const out = new Uint8Array(length);
240
+ for (let i = 0; i < length; i++)
241
+ out[i] = Number.parseInt(bytes.slice(i * 2, i * 2 + 2), 16);
242
+ return new TextDecoder("utf-8", { fatal: false }).decode(out);
243
+ }
244
+ /** The largest uint256, which several protocols use to mean "unbounded". */
245
+ export const UINT256_MAX = (1n << 256n) - 1n;
@@ -0,0 +1,21 @@
1
+ import { type RpcOptions } from "./rpc";
2
+ /** One read to issue. */
3
+ export type BatchCall = {
4
+ readonly to: string;
5
+ readonly data: string;
6
+ /** What this read is, for the failure message. */
7
+ readonly label: string;
8
+ };
9
+ /** One read's answer, at the position of the call that asked for it. */
10
+ export type CallOutcome = {
11
+ readonly ok: true;
12
+ readonly data: string;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly reason: string;
16
+ };
17
+ export type BatchOptions = RpcOptions & {
18
+ /** The chain's Multicall3 deployment, when the operator has pinned one. */
19
+ readonly multicall3?: string;
20
+ };
21
+ export declare function batchCalls(endpoint: string, calls: ReadonlyArray<BatchCall>, blockTag: string, options?: BatchOptions): Promise<ReadonlyArray<CallOutcome>>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Reading several contracts as one answer.
3
+ *
4
+ * Every read in a valuation has to come from ONE block height. A balance from
5
+ * block N and a price from block N+1 is a number that never existed, and it
6
+ * looks exactly like a number that did. Two things enforce that here:
7
+ *
8
+ * 1. The block is PINNED first — `eth_blockNumber`, then every read at that
9
+ * hex tag — so a batch is consistent whether it goes out as one
10
+ * Multicall3 call or as several plain `eth_call`s.
11
+ * 2. Multicall3 is an OPTIMISATION, not the mechanism. It is used when the
12
+ * operator has pinned its address for the chain, because one round trip
13
+ * beats forty; a chain without a pinned deployment reads the same forty
14
+ * values at the same pinned block, one call at a time. A tool that
15
+ * required Multicall3 would be wrong on every chain that predates it.
16
+ *
17
+ * The packing and unpacking is `@crewhaus/tool-onchain`'s `encodeAggregate3` /
18
+ * `decodeAggregate3`, not a local copy: the argument is an array of dynamic
19
+ * tuples and the offsets are the part that goes wrong silently.
20
+ *
21
+ * One shape difference the two paths must not leak: under Multicall3 a
22
+ * sub-call that reverts comes back as `success: false` with its revert bytes,
23
+ * while a plain `eth_call` that reverts comes back as a JSON-RPC error. Both
24
+ * land here as the same `CallOutcome` row, so nothing downstream has to know
25
+ * which path it took.
26
+ */
27
+ import { decodeAggregate3, encodeAggregate3 } from "@crewhaus/tool-onchain";
28
+ import { sanitizeContractText } from "./abi";
29
+ import { ethCall, mapPool } from "./rpc";
30
+ /** How many plain `eth_call`s are in flight at once when there is no Multicall3. */
31
+ const CALL_CONCURRENCY = 6;
32
+ export async function batchCalls(endpoint, calls, blockTag, options = {}) {
33
+ if (calls.length === 0)
34
+ return [];
35
+ if (options.multicall3 !== undefined && calls.length > 1) {
36
+ return batchThroughMulticall(endpoint, calls, blockTag, options.multicall3, options);
37
+ }
38
+ return mapPool(calls, CALL_CONCURRENCY, async (call) => {
39
+ const outcome = await ethCall(endpoint, call.to, call.data, blockTag, options);
40
+ return outcome.ok
41
+ ? { ok: true, data: outcome.value }
42
+ : { ok: false, reason: `${call.label}: ${outcome.message}` };
43
+ });
44
+ }
45
+ async function batchThroughMulticall(endpoint, calls, blockTag, multicall3, options) {
46
+ const request = encodeAggregate3(calls.map((call) => ({ target: call.to, callData: call.data, allowFailure: true })), multicall3);
47
+ const outcome = await ethCall(endpoint, request.to, request.data, blockTag, options);
48
+ if (!outcome.ok) {
49
+ // The batch itself failed, so there is no per-call answer to hand back.
50
+ // Every row carries the same reason rather than one row failing and the
51
+ // rest reading as empty.
52
+ return calls.map((call) => ({
53
+ ok: false,
54
+ reason: `${call.label}: the Multicall3 batch failed — ${outcome.message}`,
55
+ }));
56
+ }
57
+ let rows;
58
+ try {
59
+ rows = decodeAggregate3(outcome.value, calls.length);
60
+ }
61
+ catch (err) {
62
+ return calls.map((call) => ({
63
+ ok: false,
64
+ reason: `${call.label}: the Multicall3 answer could not be read — ${err.message}`,
65
+ }));
66
+ }
67
+ return rows.map((row, index) => {
68
+ const call = calls[index];
69
+ if (row.success)
70
+ return { ok: true, data: row.returnData };
71
+ // A revert string is written by the contract, and this one ends up in a
72
+ // tool result and then in a model's context. It is capped and scrubbed for
73
+ // the same reason `description()` is: a `revert(...)` is a free-text field
74
+ // an attacker controls, and a newline in it lets it impersonate the lines
75
+ // around it.
76
+ const reason = row.revert?.reason;
77
+ const safe = reason === null || reason === undefined ? null : sanitizeContractText(reason).text;
78
+ return {
79
+ ok: false,
80
+ reason: `${call.label}: the call reverted${safe === null ? "" : ` — ${safe}`}`,
81
+ };
82
+ });
83
+ }
@@ -0,0 +1,103 @@
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
+ /** A rounding mode, named at every call site that can lose a digit. */
24
+ export type RoundingMode = "halfEven" | "halfUp" | "down";
25
+ /**
26
+ * An exact decimal: `unscaled / 10^scale`. `scale` is non-negative — a value
27
+ * that would need a negative scale is normalised by multiplying the integer
28
+ * instead, so there is one representation of "this many, at this precision".
29
+ */
30
+ export type Fixed = {
31
+ readonly unscaled: bigint;
32
+ readonly scale: number;
33
+ };
34
+ export declare class DecimalError extends Error {
35
+ readonly name = "DecimalError";
36
+ }
37
+ /** Scales past this are a bug, not a price. 18 decimals plus a cross is ~40. */
38
+ export declare const MAX_SCALE = 80;
39
+ export declare function pow10(n: number): bigint;
40
+ /** Build a Fixed, normalising a negative scale into the integer part. */
41
+ export declare function fixed(unscaled: bigint, scale: number): Fixed;
42
+ export declare const ZERO: Fixed;
43
+ /** An integer as a Fixed. */
44
+ export declare function fromInteger(value: bigint): Fixed;
45
+ /**
46
+ * Parse a plain decimal string exactly.
47
+ *
48
+ * Exponent notation is refused rather than accepted: every literal this
49
+ * package reads comes from a price provider or a caller writing an amount, and
50
+ * `1e-7` in either place is far more likely to be a float that has already
51
+ * been through a double than a deliberate exponent.
52
+ */
53
+ export declare function parseFixed(text: string, what: string): Fixed;
54
+ /**
55
+ * Read a number that arrived inside a JSON document.
56
+ *
57
+ * `JSON.parse` turns a bare numeric literal into a double before anything here
58
+ * sees it, so a provider that publishes its rate as `0.85431` hands us the
59
+ * nearest double to that, not the digits. `String(value)` gives the shortest
60
+ * decimal that round-trips to the same double, which is exact for anything up
61
+ * to seventeen significant digits — an ECB reference rate has five. A provider
62
+ * that published a twenty-digit price as a bare number would lose the tail, so
63
+ * the caller is told which kind of literal a price came from rather than left
64
+ * to assume; see `PriceLiteralKind` below and the `literal` field on a quote.
65
+ */
66
+ export declare function fixedFromJson(value: unknown, what: string): {
67
+ value: Fixed;
68
+ kind: PriceLiteralKind;
69
+ };
70
+ /** Whether a price arrived as a JSON string (exact) or a JSON number (through a double). */
71
+ export type PriceLiteralKind = "string" | "number";
72
+ export declare function isZero(value: Fixed): boolean;
73
+ export declare function isPositive(value: Fixed): boolean;
74
+ export declare function negate(value: Fixed): Fixed;
75
+ export declare function absolute(value: Fixed): Fixed;
76
+ export declare function add(a: Fixed, b: Fixed): Fixed;
77
+ export declare function subtract(a: Fixed, b: Fixed): Fixed;
78
+ /** -1, 0 or 1. */
79
+ export declare function compare(a: Fixed, b: Fixed): number;
80
+ /**
81
+ * The exact product. Scales add, so nothing is rounded here — a balance at 18
82
+ * decimals times a price at 8 is a value at 26, and the rounding happens once,
83
+ * at the end, where a mode can be named for it.
84
+ */
85
+ export declare function multiply(a: Fixed, b: Fixed): Fixed;
86
+ /** Integer division with the named mode. Shared by `divide` and `roundToPlaces`. */
87
+ export declare function divideRound(numerator: bigint, denominator: bigint, mode: RoundingMode): bigint;
88
+ /** `a / b`, carried to `scale` decimal places with the named mode. */
89
+ export declare function divide(a: Fixed, b: Fixed, scale: number, mode: RoundingMode): Fixed;
90
+ /** Round to a fixed number of decimal places. */
91
+ export declare function roundToPlaces(value: Fixed, places: number, mode: RoundingMode): Fixed;
92
+ /** Drop trailing zeros without changing the value: 1.500 -> 1.5, 100 stays 100. */
93
+ export declare function trim(value: Fixed): Fixed;
94
+ /** Plain decimal notation, always. Never `1e-7`, never a float. */
95
+ export declare function toDecimalString(value: Fixed): string;
96
+ /**
97
+ * A ratio in basis points, rounded down.
98
+ *
99
+ * Down, deliberately: every caller of this is reporting a share or a spread,
100
+ * and a share rounded up can make a set of weights sum past 10000, which reads
101
+ * as an arithmetic bug in the output rather than as the rounding it is.
102
+ */
103
+ export declare function ratioBps(part: Fixed, whole: Fixed): number;