@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
|
@@ -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;
|
package/dist/lib/abi.js
ADDED
|
@@ -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;
|