@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 ADDED
@@ -0,0 +1,270 @@
1
+ # @crewhaus/tool-defi
2
+
3
+ What is it worth right now — with provenance, and an explicit unpriced bucket.
4
+
5
+ | Tool | Answers |
6
+ |---|---|
7
+ | `PriceQuote` | what does the market say this is worth, and which market said it |
8
+ | `OraclePriceRead` | what does this feed say, and what does its own freshness signal say |
9
+ | `DefiPositionRead` | what is this position, in units that are labelled |
10
+ | `PortfolioValuation` | what is all of it worth, and what could not be priced |
11
+
12
+ Nothing here signs or sends. The RPC method set is `eth_call`, `eth_getBalance`,
13
+ `eth_blockNumber` and `eth_getBlockByNumber`, checked first against
14
+ `@crewhaus/chain-adapter-base`'s shared read-only chokepoint. No schema has a
15
+ field to pass a private key, a mnemonic or a keystore to, and a test asserts
16
+ that over every schema in the package rather than trusting this paragraph.
17
+
18
+ ## "Stale" means two different things, so there is no `stale` field
19
+
20
+ Collapsing them is how a wrong oracle verdict ships, so each feed reports its
21
+ own signal under its own name.
22
+
23
+ **Chainlink** updates on its heartbeat **or** on a deviation threshold. A feed
24
+ whose price has not moved legitimately sits past its heartbeat, so elapsed time
25
+ alone is not a halt:
26
+
27
+ | Field | What it is |
28
+ |---|---|
29
+ | `answeredInRoundBehindRoundId` | `answeredInRound < roundId`. The real incomplete-round fact: a round has opened that no answer has been carried into. |
30
+ | `updatedAtZero` | the round has never been completed at all |
31
+ | `answerNotPositive` | a price feed answering zero or less is broken, not cheap |
32
+ | `secondsSinceUpdate` | `now - updatedAt`, from an **injected** clock — `null` when there is no age to take |
33
+ | `updatedAtInFuture` | `updatedAt` is ahead of the clock by more than block-timestamp skew |
34
+ | `updatedAtOutOfRange` | `updatedAt` is not a plausible unix second at all |
35
+ | `beyondHeartbeat` | `null` unless you supply the heartbeat **and** there is an age — see below |
36
+
37
+ A heartbeat is a property of the feed's deployment and the aggregator will not
38
+ tell you what it is, so `beyondHeartbeat` is `null` rather than `false` when you
39
+ do not supply one. A tool that guessed a heartbeat would be inventing the
40
+ threshold it then judges against. When you do supply one, read the answer as a
41
+ question: past the heartbeat with a complete round is the ordinary quiet-market
42
+ case, and there is a test for exactly that shape.
43
+
44
+ `beyondHeartbeat` is **also** `null` when the age cannot be taken — a feed dated
45
+ in the future, or one whose `updatedAt` is not a timestamp. `false` is a claim
46
+ ("this feed is inside its heartbeat"), and a threshold that could not be
47
+ evaluated must never be reported as one that held: a feed stamped a year from
48
+ now otherwise passes every freshness gate downstream of it. Timestamps are
49
+ carried as exact decimal **strings**, because a uint256 through a double prints
50
+ as `5.8e+76`, which is neither the value nor a time.
51
+
52
+ `description()` is contract-supplied text, and you reached the feed with an
53
+ address somebody gave you. It is capped at 128 characters and stripped of
54
+ control characters before it appears in a result, and both facts are said in
55
+ `notes` when they apply — a real aggregator's label is `"ETH / USD"`, so a long
56
+ one is a reason to check the address, not a label. The same scrub runs over
57
+ revert strings, which are free text a contract chooses too.
58
+
59
+ **Pyth** is a different quantity in different units. Its signal is
60
+ `confidenceToPriceBps` — the width of the confidence interval relative to the
61
+ price. A Pyth price two seconds old with a 400 bps band is worse than one a
62
+ minute old with a 3 bps band, and no single boolean can say that.
63
+
64
+ Every reading also carries the feed's own `description()` (`"ETH / USD"`), which
65
+ is the cheapest available check that a pinned address is the pair you meant.
66
+
67
+ ## Every price carries its provenance
68
+
69
+ Which source, which round or which fixing date, and **how it was derived**:
70
+
71
+ - `direct` — the provider publishes this pair.
72
+ - `inverted` — the provider publishes the other direction; the price is 1/theirs,
73
+ rounded half-even once, at 18 places, and the leg names the pair that was
74
+ actually published.
75
+ - `cross` — no provider publishes it, so two prices were composed. The result
76
+ names the intermediate and carries **both** legs with their own provenance.
77
+ - `oracle-direct` — read onchain at a stated block, with the round id.
78
+
79
+ A composite is only as dated as its least dated leg, so a cross with one
80
+ undated leg has `asOf: null` rather than the other leg's date.
81
+
82
+ ## Coinbase publishes no timestamp, so `asOf` is null
83
+
84
+ `/v2/prices/{pair}/spot` returns `{ "data": { base, currency, amount } }` — that
85
+ is the whole document. There is no timestamp field, so the reading carries
86
+ `asOf: null` and `asOfSource: "none"`. Stamping it with the time the response
87
+ arrived would make every price look fresh by construction.
88
+
89
+ The survey that proposed these tools assumed every provider had a timestamp to
90
+ stamp `asOf` from. This one does not, and two things follow from saying so:
91
+
92
+ 1. **A historical crypto quote is refused.** The spot endpoint echoes no date,
93
+ so an answer from it cannot be told apart from today's price — and a price
94
+ that cannot be distinguished from today's must not be labelled as last
95
+ Tuesday's. The refusal names what to do instead: read a Chainlink round at a
96
+ pinned block, or quote the asset against a currency the ECB publishes.
97
+ 2. **A fiat rate is a different story.** Frankfurter republishes the ECB's daily
98
+ reference rates, one fixing per TARGET business day at about 16:00 CET. Ask
99
+ for a Saturday and the answer comes back stamped with Friday's fixing — that
100
+ returned date is the `asOf`, and it is what makes a reconciliation reproduce.
101
+
102
+ One smaller honesty: Frankfurter publishes its rates as bare JSON **numbers**,
103
+ which `JSON.parse` turns into doubles before anything here sees them, while
104
+ Coinbase publishes strings. Each leg says which it was in `literal`. Five
105
+ significant digits survive a double intact; twenty would not, and the field is
106
+ there so nobody has to guess which case they are in.
107
+
108
+ ## The unpriced bucket
109
+
110
+ `PortfolioValuation` returns three separate fields, and the middle one is not
111
+ the sum of the other two:
112
+
113
+ ```
114
+ total the value of the PRICED holdings, and it says so in the payload
115
+ priced[] one row per holding with an amount, a price, a value, a weight
116
+ and the provenance of that row's own price
117
+ unpriced[] one row per holding that could not be priced: the asset, the
118
+ amount when it is known, and WHY
119
+ ```
120
+
121
+ A total that silently drops the assets it could not price is a number somebody
122
+ will put in a report, and it will be wrong in the direction that looks good. So
123
+ a holding goes to `unpriced[]` — never to zero, never to absence — when:
124
+
125
+ - it names no price source, or more than one;
126
+ - it names more than one **amount** source (`amount`, `baseUnits`, `token`,
127
+ `native`) — preferring one silently would hide which figure the row is;
128
+ - its amount and its price between them need more decimal places than this
129
+ carries, so the product is refused (one row's arithmetic is one row);
130
+ - its oracle's round is incomplete, or the feed answered zero or less;
131
+ - its Pyth confidence band is past the bound you supplied;
132
+ - its token's `decimals()` could not be read and none was given (defaulting to
133
+ 18 on a six-decimal token is a factor of a trillion);
134
+ - a provider refused, rate-limited, or answered in the wrong currency.
135
+
136
+ `weightBps` is a share of the **priced** total, which is stated in the payload
137
+ too — the weights of an incomplete portfolio still sum to 10000, and that is
138
+ only honest if the reader knows what they are a share of.
139
+
140
+ `minValue` flags small rows as dust and summarises them. It does **not** remove
141
+ them from the total: a floor that took value out of a total would be the same
142
+ silent loss as an unpriced asset wearing a different hat.
143
+
144
+ ### One block, or a refusal
145
+
146
+ Every read in one answer shares one block tag. When no block is given, the head
147
+ is pinned with `eth_blockNumber` first and every read uses that — two reads
148
+ tagged `latest` a second apart can land on different blocks, and the valuation
149
+ is then a number that never existed at any height.
150
+
151
+ A historical valuation must pin **both** ends:
152
+
153
+ | `blockNumber` | `at` | Quote-provider prices? | |
154
+ |---|---|---|---|
155
+ | — | — | yes | live balances, live prices |
156
+ | set | — | none | balances and oracle prices at the same block |
157
+ | set | set | yes | balances at the block, prices at the date |
158
+ | — | set | — | **refused** — resolving a date to a block is `EvmBlockAtTimestamp` |
159
+ | set | — | yes | **refused** — a historical balance at today's price |
160
+
161
+ ## Positions: each protocol's own basis, labelled
162
+
163
+ `aave-v3`, `compound-v3` and `erc4626`. Everything else is refused by name with
164
+ what reading it would take — `morpho-blue` needs the market's own oracle read at
165
+ the same block, a Uniswap v3 LP position needs tick math, a Maker vault is an
166
+ urn addressed by ilk. An unpinned protocol is refused, never probed.
167
+
168
+ - **Aave v3** answers in the price oracle's base currency. The base unit is a
169
+ market parameter, not a constant, so `baseCurrencyDecimals` is an input with a
170
+ default of 8 (what the USD markets use) and a `baseCurrencyDecimalsAssumed`
171
+ flag saying whether the default was taken. Its health factor for a debt-free
172
+ account is `type(uint256).max`, which read as a wad is 1.15e59 and prints as a
173
+ spectacularly safe position — so it is reported as `null` with a reason.
174
+ - **Compound v3** has one borrowable asset, so a supply balance and a borrow
175
+ balance are mutually exclusive, and both are in base-token units. Its
176
+ liquidation verdict comes from the protocol's own `isLiquidatable(address)`
177
+ rather than being derived, because Comet's collateral factors are per asset
178
+ and a derived answer would disagree with the protocol UI.
179
+ - **ERC-4626** shares are worth `convertToAssets(shares)`, not
180
+ `totalAssets()/totalSupply()`. The two differ for any vault with an entry or
181
+ exit fee, and the second is the one that looks right.
182
+
183
+ ### No liquidation price
184
+
185
+ `collateralDropToLiquidationBps` instead: how far the whole collateral basket
186
+ may fall before the health factor reaches 1. A liquidation *price* for an
187
+ aggregate position is not a fact — it depends on which collateral you assume
188
+ moves, and picking one produces a number that looks authoritative and disagrees
189
+ with the protocol UI. The drop needs no assumption and is what the liquidation
190
+ price was being used to approximate. A position already below 1 reports 0 rather
191
+ than a negative number.
192
+
193
+ ## Configuration
194
+
195
+ Endpoints and pinned feeds come from the `defi` `tool_config` block, or from
196
+ `registerDefiConfig(...)` at boot. **No caller ever supplies a URL** — that is
197
+ what keeps the network surface short: there is no model-chosen host to defend,
198
+ so no allow-list to widen and no SSRF gate to get subtly wrong, and an
199
+ operator's own node on `127.0.0.1:8545` is a first-class endpoint rather than
200
+ something a private-address rule has to be argued out of.
201
+
202
+ ```jsonc
203
+ {
204
+ "rpc": { "1": "https://eth-mainnet.example/v2/KEY" },
205
+ "multicall3": { "1": "0xcA11bde05977b3631167028862bE2a173976CA11" },
206
+ "feeds": {
207
+ "eth-usd": {
208
+ "chain_id": "1",
209
+ "kind": "chainlink",
210
+ "address": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
211
+ "heartbeat_seconds": 3600
212
+ }
213
+ }
214
+ }
215
+ ```
216
+
217
+ A per-call block **replaces** the boot registration rather than merging into it,
218
+ which is the same replace semantics every other tool_config block here has.
219
+ `multicall3` is an optimisation, not a requirement: with it, forty reads are one
220
+ round trip; without it, they are forty reads at the same pinned block. A chain
221
+ that predates Multicall3 works either way, and a test runs the same scenario
222
+ both ways and compares the answers.
223
+
224
+ An endpoint appears in output and in refusals as its **origin only**. Provider
225
+ RPC URLs carry the API key in the path, and a refusal is not the place to put
226
+ one into a transcript.
227
+
228
+ That holds for borrowed strings too, which is the harder half: a dialler that
229
+ reports `unable to connect to <url>`, or a node that quotes your project path
230
+ back at you in its own error, is handing over the credential inside a message
231
+ this package only concatenates. The URL, its path and its query are redacted
232
+ out of every such message before it becomes a failure — the host stays, because
233
+ a refusal naming no host is unactionable.
234
+
235
+ ## What is borrowed, and what is not
236
+
237
+ - **The ABI coder is `@crewhaus/tool-onchain`'s**, and the Multicall3 packing is
238
+ its `encodeAggregate3`/`decodeAggregate3`. The handful of selectors this
239
+ package issues are constants here — there is no Keccak in its dependency set —
240
+ and every one of them is put through tool-onchain's `FunctionSelector` in the
241
+ suite, alongside every encoder and decoder against `AbiEncodeCall` and
242
+ `AbiDecode`. A hand-rolled decoder tested against a hand-rolled encoder agrees
243
+ with itself about any mistake the two share.
244
+ - **Addresses are shape-checked, not checksum-verified.** EIP-55 needs Keccak.
245
+ Run `AddressCheck` in `@crewhaus/tool-onchain` on an address before pinning it
246
+ — reading the wrong contract is not an error this package can detect, it is a
247
+ wrong answer.
248
+ - **The currency table is `@crewhaus/tool-math`'s** `CURRENCY_MINOR_UNITS`, which
249
+ is what sets the default decimal places for a total: a yen is not divisible,
250
+ so two places there would be an invented precision.
251
+ - **The decimal arithmetic is local**, in `src/lib/decimal.ts`, because
252
+ tool-math's kernel is module-private behind its entrypoint. Every rounding
253
+ case in it is pinned against tool-math's `Round` tool — through that public
254
+ surface — on the ties and modes where two implementations diverge if they ever
255
+ will.
256
+
257
+ ## Testing
258
+
259
+ Two seams, both exported beside the tools: `_setFetch` replaces the dialler and
260
+ `_setClock` replaces the clock. Every test drives them, several assert that
261
+ nothing was dialled at all, and nothing in the suite resolves a name or opens a
262
+ socket — a test that reached a public RPC would fail on a runner with no egress
263
+ and flake on somebody else's rate limit, and one that reached a price provider
264
+ would assert today's Bitcoin price.
265
+
266
+ `src/fixtures.ts` holds the recorded chain and the two recorded provider
267
+ documents. The fixture node really implements Multicall3 — it decodes the
268
+ `aggregate3` calldata, dispatches each sub-call against the same routing table
269
+ and re-encodes the results — so the batch path is the path production takes
270
+ rather than a special case that proves nothing.
@@ -0,0 +1,111 @@
1
+ import type { DefiFetch } from "./lib/rpc";
2
+ /** ABI-encode a tuple of values, as a contract's return data (no selector). */
3
+ export declare function encodeReturn(types: ReadonlyArray<string>, args: ReadonlyArray<unknown>): Promise<string>;
4
+ /** Mainnet-shaped addresses. Lowercase: this package shape-checks, it does not checksum. */
5
+ export declare const ADDR: Readonly<{
6
+ wallet: "0x1111111111111111111111111111111111111111";
7
+ chainlinkEthUsd: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419";
8
+ pyth: "0x4305fb66699c3b2702d4d05cf36551390a4c69c6";
9
+ aavePool: "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2";
10
+ comet: "0xc3d688b66703497daa19211eedff47f25384cdc3";
11
+ vault4626: "0x83f20f44975d03b1b09e64809b757c47f942beea";
12
+ usdc: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
13
+ weth: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
14
+ multicall3: "0xca11bde05977b3631167028862be2a173976ca11";
15
+ spam: "0xdead000000000000000000000000000000000001";
16
+ }>;
17
+ export declare const SELECTOR_TEXT: Readonly<{
18
+ decimals: "0x313ce567";
19
+ description: "0x7284e416";
20
+ latestRoundData: "0xfeaf968c";
21
+ balanceOf: "0x70a08231";
22
+ getPriceUnsafe: "0x96834ad3";
23
+ getUserAccountData: "0xbf92857c";
24
+ convertToAssets: "0x07a2d13a";
25
+ asset: "0x38d52e0f";
26
+ borrowBalanceOf: "0x374c49b4";
27
+ baseToken: "0xc55dae63";
28
+ collateralBalanceOf: "0x5c2549ee";
29
+ isLiquidatable: "0x042e02cf";
30
+ aggregate3: "0x82ad56cb";
31
+ }>;
32
+ /** A Pyth price id. Any 32 bytes will do; this is the shape, not the value. */
33
+ export declare const ETH_USD_PRICE_ID: string;
34
+ /** A sub-call's answer: return data, or a revert. */
35
+ export type CallAnswer = {
36
+ readonly data: string;
37
+ } | {
38
+ readonly revert: string;
39
+ };
40
+ export type ChainState = {
41
+ blockNumber: bigint;
42
+ /** `${to}|${calldata}` -> answer. Exact calldata, so a wrong argument misses. */
43
+ readonly calls: Map<string, CallAnswer>;
44
+ /** address -> wei. */
45
+ readonly balances: Map<string, bigint>;
46
+ /** Where Multicall3 is, when this chain has it. */
47
+ multicall3?: string;
48
+ };
49
+ export declare function newChain(blockNumber?: bigint): ChainState;
50
+ export declare function route(chain: ChainState, to: string, calldata: string, answer: CallAnswer): void;
51
+ /** `latestRoundData()`, `decimals()` and `description()` for one Chainlink feed. */
52
+ export declare function chainlinkFeed(chain: ChainState, address: string, feed: {
53
+ roundId: bigint;
54
+ answer: bigint;
55
+ startedAt: bigint;
56
+ updatedAt: bigint;
57
+ answeredInRound: bigint;
58
+ decimals: number;
59
+ description?: string | null;
60
+ }): Promise<void>;
61
+ /** One Pyth price, under `getPriceUnsafe(priceId)`. */
62
+ export declare function pythFeed(chain: ChainState, address: string, priceId: string, price: {
63
+ price: bigint;
64
+ confidence: bigint;
65
+ exponent: number;
66
+ publishTime: bigint;
67
+ }): Promise<void>;
68
+ /** An ERC-20's `balanceOf(wallet)` and `decimals()`. */
69
+ export declare function erc20(chain: ChainState, token: string, wallet: string, balance: bigint, decimals: number | null): Promise<void>;
70
+ /** Aave v3 `getUserAccountData(account)`. */
71
+ export declare function aaveAccount(chain: ChainState, pool: string, account: string, data: {
72
+ collateralBase: bigint;
73
+ debtBase: bigint;
74
+ availableBorrowsBase: bigint;
75
+ liquidationThresholdBps: bigint;
76
+ ltvBps: bigint;
77
+ healthFactorWad: bigint;
78
+ }): Promise<void>;
79
+ export type Recorded = {
80
+ method: string;
81
+ url: string;
82
+ rpcMethod?: string;
83
+ body?: unknown;
84
+ };
85
+ export type ServeOptions = {
86
+ readonly chain?: ChainState;
87
+ /** Absolute URL -> body, or a status to answer with. */
88
+ readonly http?: Record<string, unknown | {
89
+ status: number;
90
+ headers?: Record<string, string>;
91
+ }>;
92
+ /** Called before anything is served, so a test can fail one request. */
93
+ readonly intercept?: (req: Request) => Response | undefined;
94
+ };
95
+ /**
96
+ * Build the fetch stub. It records every request so a test can assert what was
97
+ * NOT asked for, which is how the "a cross only happens when a direct quote
98
+ * failed" claims are checked.
99
+ */
100
+ export declare function serve(options: ServeOptions): {
101
+ fetch: DefiFetch;
102
+ recorded: Recorded[];
103
+ };
104
+ /** Frankfurter's shape. The rate is a bare JSON NUMBER, which is the point. */
105
+ export declare function frankfurter(base: string, quote: string, rate: number, date: string): unknown;
106
+ /** Coinbase's shape. The amount is a STRING, and there is no timestamp anywhere. */
107
+ export declare function coinbaseSpot(base: string, currency: string, amount: string): unknown;
108
+ export declare const FRANKFURTER = "https://api.frankfurter.app";
109
+ export declare const COINBASE = "https://api.coinbase.com";
110
+ export declare const frankfurterUrl: (base: string, quote: string, at?: string) => string;
111
+ export declare const coinbaseUrl: (base: string, quote: string) => string;
@@ -0,0 +1,261 @@
1
+ /**
2
+ * A recorded chain and two recorded price providers, and the stub that serves
3
+ * them.
4
+ *
5
+ * Nothing in this package's suite opens a socket: `_setFetch` is replaced with
6
+ * `serve(...)` and every byte a test sees comes from here. That is not just
7
+ * hygiene — a test that dialled a public RPC would fail on a runner with no
8
+ * egress and flake on somebody else's rate limit, and a test that dialled a
9
+ * price provider would assert today's Bitcoin price.
10
+ *
11
+ * Two things this file does deliberately:
12
+ *
13
+ * 1. **Return data is encoded by `@crewhaus/tool-onchain`'s own ABI coder**,
14
+ * through its public `AbiEncodeCall` tool, rather than by hand. The
15
+ * decoders in `./lib/abi` are therefore checked against the real encoder
16
+ * instead of against a second hand-rolled version of themselves, which
17
+ * would agree with them about any mistake they shared.
18
+ * 2. **The node really implements Multicall3.** `aggregate3` calldata is
19
+ * decoded, each sub-call is dispatched against the same routing table,
20
+ * and the results are re-encoded — so the same scenario can be run with
21
+ * and without a pinned Multicall3 address and the two answers compared.
22
+ * A stub that special-cased the batch would prove nothing about the path
23
+ * production takes.
24
+ */
25
+ import { abiDecode, abiEncodeCall } from "@crewhaus/tool-onchain";
26
+ // biome-ignore lint/suspicious/noExplicitAny: the executor supplies this context, and these two tools never read it.
27
+ const ctx = {};
28
+ /** ABI-encode a tuple of values, as a contract's return data (no selector). */
29
+ export async function encodeReturn(types, args) {
30
+ const out = JSON.parse((await abiEncodeCall.execute({ signature: `f(${types.join(",")})`, args }, ctx)));
31
+ return `0x${out.data.slice(10)}`;
32
+ }
33
+ async function decodeOne(type, data) {
34
+ const out = JSON.parse((await abiDecode.execute({ data, types: [type] }, ctx)));
35
+ return out.values[0];
36
+ }
37
+ // ─── addresses ──────────────────────────────────────────────────────────────
38
+ /** Mainnet-shaped addresses. Lowercase: this package shape-checks, it does not checksum. */
39
+ export const ADDR = Object.freeze({
40
+ wallet: "0x1111111111111111111111111111111111111111",
41
+ chainlinkEthUsd: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419",
42
+ pyth: "0x4305fb66699c3b2702d4d05cf36551390a4c69c6",
43
+ aavePool: "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2",
44
+ comet: "0xc3d688b66703497daa19211eedff47f25384cdc3",
45
+ vault4626: "0x83f20f44975d03b1b09e64809b757c47f942beea",
46
+ usdc: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
47
+ weth: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
48
+ multicall3: "0xca11bde05977b3631167028862be2a173976ca11",
49
+ spam: "0xdead000000000000000000000000000000000001",
50
+ });
51
+ export const SELECTOR_TEXT = Object.freeze({
52
+ decimals: "0x313ce567",
53
+ description: "0x7284e416",
54
+ latestRoundData: "0xfeaf968c",
55
+ balanceOf: "0x70a08231",
56
+ getPriceUnsafe: "0x96834ad3",
57
+ getUserAccountData: "0xbf92857c",
58
+ convertToAssets: "0x07a2d13a",
59
+ asset: "0x38d52e0f",
60
+ borrowBalanceOf: "0x374c49b4",
61
+ baseToken: "0xc55dae63",
62
+ collateralBalanceOf: "0x5c2549ee",
63
+ isLiquidatable: "0x042e02cf",
64
+ aggregate3: "0x82ad56cb",
65
+ });
66
+ /** A Pyth price id. Any 32 bytes will do; this is the shape, not the value. */
67
+ export const ETH_USD_PRICE_ID = `0x${"ff".repeat(32)}`;
68
+ export function newChain(blockNumber = 21000000n) {
69
+ return { blockNumber, calls: new Map(), balances: new Map() };
70
+ }
71
+ export function route(chain, to, calldata, answer) {
72
+ chain.calls.set(`${to.toLowerCase()}|${calldata.toLowerCase()}`, answer);
73
+ }
74
+ /** `latestRoundData()`, `decimals()` and `description()` for one Chainlink feed. */
75
+ export async function chainlinkFeed(chain, address, feed) {
76
+ route(chain, address, SELECTOR_TEXT.latestRoundData, {
77
+ data: await encodeReturn(["uint80", "int256", "uint256", "uint256", "uint80"], [
78
+ feed.roundId.toString(),
79
+ feed.answer.toString(),
80
+ feed.startedAt.toString(),
81
+ feed.updatedAt.toString(),
82
+ feed.answeredInRound.toString(),
83
+ ]),
84
+ });
85
+ route(chain, address, SELECTOR_TEXT.decimals, {
86
+ data: await encodeReturn(["uint8"], [String(feed.decimals)]),
87
+ });
88
+ if (feed.description === null) {
89
+ route(chain, address, SELECTOR_TEXT.description, { revert: "0x" });
90
+ }
91
+ else {
92
+ route(chain, address, SELECTOR_TEXT.description, {
93
+ data: await encodeReturn(["string"], [feed.description ?? "ETH / USD"]),
94
+ });
95
+ }
96
+ }
97
+ /** One Pyth price, under `getPriceUnsafe(priceId)`. */
98
+ export async function pythFeed(chain, address, priceId, price) {
99
+ const calldata = SELECTOR_TEXT.getPriceUnsafe + priceId.replace(/^0x/, "");
100
+ route(chain, address, calldata, {
101
+ data: await encodeReturn(["int64", "uint64", "int32", "uint256"], [
102
+ price.price.toString(),
103
+ price.confidence.toString(),
104
+ String(price.exponent),
105
+ price.publishTime.toString(),
106
+ ]),
107
+ });
108
+ }
109
+ /** An ERC-20's `balanceOf(wallet)` and `decimals()`. */
110
+ export async function erc20(chain, token, wallet, balance, decimals) {
111
+ route(chain, token, SELECTOR_TEXT.balanceOf + wallet.replace(/^0x/, "").padStart(64, "0"), {
112
+ data: await encodeReturn(["uint256"], [balance.toString()]),
113
+ });
114
+ if (decimals === null) {
115
+ route(chain, token, SELECTOR_TEXT.decimals, { revert: "0x" });
116
+ }
117
+ else {
118
+ route(chain, token, SELECTOR_TEXT.decimals, {
119
+ data: await encodeReturn(["uint8"], [String(decimals)]),
120
+ });
121
+ }
122
+ }
123
+ /** Aave v3 `getUserAccountData(account)`. */
124
+ export async function aaveAccount(chain, pool, account, data) {
125
+ route(chain, pool, SELECTOR_TEXT.getUserAccountData + account.replace(/^0x/, "").padStart(64, "0"), {
126
+ data: await encodeReturn(["uint256", "uint256", "uint256", "uint256", "uint256", "uint256"], [
127
+ data.collateralBase.toString(),
128
+ data.debtBase.toString(),
129
+ data.availableBorrowsBase.toString(),
130
+ data.liquidationThresholdBps.toString(),
131
+ data.ltvBps.toString(),
132
+ data.healthFactorWad.toString(),
133
+ ]),
134
+ });
135
+ }
136
+ /**
137
+ * Build the fetch stub. It records every request so a test can assert what was
138
+ * NOT asked for, which is how the "a cross only happens when a direct quote
139
+ * failed" claims are checked.
140
+ */
141
+ export function serve(options) {
142
+ const recorded = [];
143
+ const fetch = async (req) => {
144
+ const intercepted = options.intercept?.(req);
145
+ if (intercepted !== undefined) {
146
+ recorded.push({ method: req.method, url: req.url });
147
+ return intercepted;
148
+ }
149
+ if (req.method === "POST") {
150
+ const body = (await req.json());
151
+ recorded.push({ method: "POST", url: req.url, rpcMethod: body.method, body: body.params });
152
+ if (options.chain === undefined) {
153
+ return json({
154
+ jsonrpc: "2.0",
155
+ id: 1,
156
+ error: { code: -32601, message: "no chain in this fixture" },
157
+ });
158
+ }
159
+ return handleRpc(options.chain, body);
160
+ }
161
+ recorded.push({ method: "GET", url: req.url });
162
+ const route = options.http?.[req.url];
163
+ if (route === undefined) {
164
+ return new Response(JSON.stringify({ message: "not found" }), { status: 404 });
165
+ }
166
+ const asStatus = route;
167
+ if (typeof asStatus?.status === "number") {
168
+ return new Response("", { status: asStatus.status, headers: asStatus.headers ?? {} });
169
+ }
170
+ return new Response(JSON.stringify(route), { status: 200 });
171
+ };
172
+ return { fetch, recorded };
173
+ }
174
+ function json(value, status = 200) {
175
+ return new Response(JSON.stringify(value), {
176
+ status,
177
+ headers: { "content-type": "application/json" },
178
+ });
179
+ }
180
+ async function handleRpc(chain, body) {
181
+ if (body.method === "eth_blockNumber") {
182
+ return json({ jsonrpc: "2.0", id: 1, result: `0x${chain.blockNumber.toString(16)}` });
183
+ }
184
+ if (body.method === "eth_getBalance") {
185
+ const address = String(body.params[0] ?? "").toLowerCase();
186
+ const balance = chain.balances.get(address);
187
+ if (balance === undefined) {
188
+ return json({
189
+ jsonrpc: "2.0",
190
+ id: 1,
191
+ error: { code: -32000, message: `no balance recorded for ${address}` },
192
+ });
193
+ }
194
+ return json({ jsonrpc: "2.0", id: 1, result: `0x${balance.toString(16)}` });
195
+ }
196
+ if (body.method !== "eth_call") {
197
+ return json({
198
+ jsonrpc: "2.0",
199
+ id: 1,
200
+ error: { code: -32601, message: `unexpected method ${body.method}` },
201
+ });
202
+ }
203
+ const call = body.params[0];
204
+ const to = call.to.toLowerCase();
205
+ const data = call.data.toLowerCase();
206
+ if (chain.multicall3 !== undefined && to === chain.multicall3.toLowerCase()) {
207
+ if (!data.startsWith(SELECTOR_TEXT.aggregate3)) {
208
+ return json({
209
+ jsonrpc: "2.0",
210
+ id: 1,
211
+ error: { code: -32000, message: "not an aggregate3 call" },
212
+ });
213
+ }
214
+ const decoded = (await decodeOne("(address,bool,bytes)[]", `0x${data.slice(10)}`));
215
+ const rows = [];
216
+ for (const [target, allowFailure, callData] of decoded) {
217
+ const answer = chain.calls.get(`${target.toLowerCase()}|${callData.toLowerCase()}`);
218
+ if (answer === undefined || "revert" in answer) {
219
+ if (!allowFailure) {
220
+ return json({
221
+ jsonrpc: "2.0",
222
+ id: 1,
223
+ error: { code: -32000, message: "a sub-call reverted" },
224
+ });
225
+ }
226
+ rows.push([false, answer === undefined ? "0x" : answer.revert]);
227
+ continue;
228
+ }
229
+ rows.push([true, answer.data]);
230
+ }
231
+ return json({ jsonrpc: "2.0", id: 1, result: await encodeReturn(["(bool,bytes)[]"], [rows]) });
232
+ }
233
+ const answer = chain.calls.get(`${to}|${data}`);
234
+ if (answer === undefined) {
235
+ return json({
236
+ jsonrpc: "2.0",
237
+ id: 1,
238
+ error: {
239
+ code: -32000,
240
+ message: `execution reverted: no route for ${to} ${data.slice(0, 10)}`,
241
+ },
242
+ });
243
+ }
244
+ if ("revert" in answer) {
245
+ return json({ jsonrpc: "2.0", id: 1, error: { code: 3, message: "execution reverted" } });
246
+ }
247
+ return json({ jsonrpc: "2.0", id: 1, result: answer.data });
248
+ }
249
+ // ─── recorded provider documents ────────────────────────────────────────────
250
+ /** Frankfurter's shape. The rate is a bare JSON NUMBER, which is the point. */
251
+ export function frankfurter(base, quote, rate, date) {
252
+ return { amount: 1.0, base, date, rates: { [quote]: rate } };
253
+ }
254
+ /** Coinbase's shape. The amount is a STRING, and there is no timestamp anywhere. */
255
+ export function coinbaseSpot(base, currency, amount) {
256
+ return { data: { base, currency, amount } };
257
+ }
258
+ export const FRANKFURTER = "https://api.frankfurter.app";
259
+ export const COINBASE = "https://api.coinbase.com";
260
+ export const frankfurterUrl = (base, quote, at) => `${FRANKFURTER}/${at ?? "latest"}?from=${base}&to=${quote}`;
261
+ export const coinbaseUrl = (base, quote) => `${COINBASE}/v2/prices/${base}-${quote}/spot`;
@@ -0,0 +1,13 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ /** The seams, and the config. Every test in this package drives the first two. */
3
+ export { _setFetch, _setClock, _resetDefiConfig, registerDefiConfig, getDefiConfig, type Clock, type DefiConfig, type DefiConfigInput, type DefiFetch, DefiError, } from "./lib/rpc";
4
+ export { PROVIDER_ORIGINS, type ProviderName } from "./lib/quotes";
5
+ export { SUPPORTED_PROTOCOLS, REFUSED_PROTOCOLS, type Protocol } from "./lib/positions";
6
+ export declare const priceQuote: RegisteredTool;
7
+ export declare const oraclePriceRead: RegisteredTool;
8
+ export declare const defiPositionRead: RegisteredTool;
9
+ export declare const portfolioValuation: RegisteredTool;
10
+ /** Every tool this package registers, in the order a catalog should list them. */
11
+ export declare const DEFI_TOOLS: ReadonlyArray<RegisteredTool>;
12
+ /** Re-exported for callers assembling their own valuations from the same maths. */
13
+ export { type Fixed, type RoundingMode, add, compare, fixed, fromInteger, parseFixed, ratioBps, roundToPlaces, toDecimalString, } from "./lib/decimal";