@crewhaus/tool-defi 0.7.0 → 0.8.1
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +27 -4
- package/dist/index.d.ts +21 -0
- package/dist/index.js +283 -120
- package/dist/lib/abi.d.ts +27 -7
- package/dist/lib/abi.js +35 -7
- package/dist/lib/oracle.js +13 -2
- package/dist/lib/quotes.js +17 -5
- package/dist/lib/rpc.d.ts +54 -1
- package/dist/lib/rpc.js +129 -61
- package/package.json +14 -9
- package/dist/fixtures.d.ts +0 -111
- package/dist/fixtures.js +0 -261
package/dist/fixtures.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
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`;
|