@crewhaus/tool-defi 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +270 -0
- package/dist/fixtures.d.ts +111 -0
- package/dist/fixtures.js +261 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +918 -0
- package/dist/lib/abi.d.ts +147 -0
- package/dist/lib/abi.js +245 -0
- package/dist/lib/batch.d.ts +21 -0
- package/dist/lib/batch.js +83 -0
- package/dist/lib/decimal.d.ts +103 -0
- package/dist/lib/decimal.js +246 -0
- package/dist/lib/oracle.d.ts +147 -0
- package/dist/lib/oracle.js +270 -0
- package/dist/lib/positions.d.ts +51 -0
- package/dist/lib/positions.js +301 -0
- package/dist/lib/quotes.d.ts +85 -0
- package/dist/lib/quotes.js +370 -0
- package/dist/lib/rpc.d.ts +171 -0
- package/dist/lib/rpc.js +600 -0
- package/package.json +48 -0
package/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;
|
package/dist/fixtures.js
ADDED
|
@@ -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`;
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|