@crewhaus/tool-onchain 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 +111 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +305 -0
- package/dist/lib/abi.d.ts +29 -0
- package/dist/lib/abi.js +429 -0
- package/dist/lib/address.d.ts +22 -0
- package/dist/lib/address.js +90 -0
- package/dist/lib/defi.d.ts +50 -0
- package/dist/lib/defi.js +94 -0
- package/dist/lib/multicall.d.ts +106 -0
- package/dist/lib/multicall.js +207 -0
- package/dist/lib/typed.d.ts +46 -0
- package/dist/lib/typed.js +171 -0
- package/package.json +46 -0
package/dist/lib/abi.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ABI encoding and decoding.
|
|
3
|
+
*
|
|
4
|
+
* Producing calldata by hand is exactly the kind of work that looks easy and
|
|
5
|
+
* is not: every value is padded into a 32-byte word, dynamic values move to
|
|
6
|
+
* a tail and leave an offset behind, and a wrong offset produces calldata
|
|
7
|
+
* that a node accepts and a contract misreads. A model asked to do this will
|
|
8
|
+
* sometimes produce something plausible, and the transaction is irreversible.
|
|
9
|
+
*
|
|
10
|
+
* Integers are handled as `bigint` throughout. A `uint256` does not fit in a
|
|
11
|
+
* double, and reading one as a number silently loses the low bits of any
|
|
12
|
+
* balance above about nine quadrillion wei — which is nine thousandths of an
|
|
13
|
+
* ether.
|
|
14
|
+
*/
|
|
15
|
+
import { keccak256, toHex } from "@crewhaus/tool-encode";
|
|
16
|
+
const WORD = 32;
|
|
17
|
+
const INT_RE = /^(u?int)(\d*)$/;
|
|
18
|
+
const BYTES_RE = /^bytes(\d*)$/;
|
|
19
|
+
/** Split a top-level comma list, respecting nested parentheses and brackets. */
|
|
20
|
+
function splitTop(text) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let current = "";
|
|
24
|
+
for (const ch of text) {
|
|
25
|
+
if (ch === "(" || ch === "[")
|
|
26
|
+
depth++;
|
|
27
|
+
else if (ch === ")" || ch === "]")
|
|
28
|
+
depth--;
|
|
29
|
+
if (ch === "," && depth === 0) {
|
|
30
|
+
parts.push(current.trim());
|
|
31
|
+
current = "";
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
current += ch;
|
|
35
|
+
}
|
|
36
|
+
if (current.trim() !== "")
|
|
37
|
+
parts.push(current.trim());
|
|
38
|
+
return parts;
|
|
39
|
+
}
|
|
40
|
+
export function parseType(raw) {
|
|
41
|
+
const text = raw.trim();
|
|
42
|
+
if (text === "")
|
|
43
|
+
throw new Error("an empty string is not an ABI type");
|
|
44
|
+
// Array suffix, innermost last: `uint256[2][]` is a dynamic array of
|
|
45
|
+
// fixed-length-2 arrays, so the LAST suffix is the outer type.
|
|
46
|
+
const arrayMatch = /^(.*)\[(\d*)\]$/.exec(text);
|
|
47
|
+
if (arrayMatch) {
|
|
48
|
+
const child = parseType(arrayMatch[1]);
|
|
49
|
+
const length = arrayMatch[2] === "" ? -1 : Number.parseInt(arrayMatch[2], 10);
|
|
50
|
+
if (length === 0)
|
|
51
|
+
throw new Error(`"${text}" has a zero-length array, which cannot hold a value`);
|
|
52
|
+
return {
|
|
53
|
+
base: "array",
|
|
54
|
+
bits: 0,
|
|
55
|
+
arrayLength: length,
|
|
56
|
+
child,
|
|
57
|
+
components: [],
|
|
58
|
+
dynamic: length === -1 || child.dynamic,
|
|
59
|
+
canonical: `${child.canonical}[${length === -1 ? "" : length}]`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (text.startsWith("(") && text.endsWith(")")) {
|
|
63
|
+
const components = splitTop(text.slice(1, -1)).map(parseType);
|
|
64
|
+
return {
|
|
65
|
+
base: "tuple",
|
|
66
|
+
bits: 0,
|
|
67
|
+
arrayLength: null,
|
|
68
|
+
child: null,
|
|
69
|
+
components,
|
|
70
|
+
dynamic: components.some((c) => c.dynamic),
|
|
71
|
+
canonical: `(${components.map((c) => c.canonical).join(",")})`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (text === "address") {
|
|
75
|
+
return {
|
|
76
|
+
base: "address",
|
|
77
|
+
bits: 160,
|
|
78
|
+
arrayLength: null,
|
|
79
|
+
child: null,
|
|
80
|
+
components: [],
|
|
81
|
+
dynamic: false,
|
|
82
|
+
canonical: "address",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (text === "bool") {
|
|
86
|
+
return {
|
|
87
|
+
base: "bool",
|
|
88
|
+
bits: 8,
|
|
89
|
+
arrayLength: null,
|
|
90
|
+
child: null,
|
|
91
|
+
components: [],
|
|
92
|
+
dynamic: false,
|
|
93
|
+
canonical: "bool",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (text === "string") {
|
|
97
|
+
return {
|
|
98
|
+
base: "string",
|
|
99
|
+
bits: 0,
|
|
100
|
+
arrayLength: null,
|
|
101
|
+
child: null,
|
|
102
|
+
components: [],
|
|
103
|
+
dynamic: true,
|
|
104
|
+
canonical: "string",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const intMatch = INT_RE.exec(text);
|
|
108
|
+
if (intMatch) {
|
|
109
|
+
// Bare `uint` and `int` mean 256 bits, and the canonical form — the one
|
|
110
|
+
// the selector is hashed over — always spells it out.
|
|
111
|
+
const bits = intMatch[2] === "" ? 256 : Number.parseInt(intMatch[2], 10);
|
|
112
|
+
if (bits % 8 !== 0 || bits < 8 || bits > 256) {
|
|
113
|
+
throw new Error(`"${text}" is not a valid integer width; it must be a multiple of 8 from 8 to 256`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
base: intMatch[1],
|
|
117
|
+
bits,
|
|
118
|
+
arrayLength: null,
|
|
119
|
+
child: null,
|
|
120
|
+
components: [],
|
|
121
|
+
dynamic: false,
|
|
122
|
+
canonical: `${intMatch[1]}${bits}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const bytesMatch = BYTES_RE.exec(text);
|
|
126
|
+
if (bytesMatch) {
|
|
127
|
+
if (bytesMatch[1] === "") {
|
|
128
|
+
return {
|
|
129
|
+
base: "bytes",
|
|
130
|
+
bits: 0,
|
|
131
|
+
arrayLength: null,
|
|
132
|
+
child: null,
|
|
133
|
+
components: [],
|
|
134
|
+
dynamic: true,
|
|
135
|
+
canonical: "bytes",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const size = Number.parseInt(bytesMatch[1], 10);
|
|
139
|
+
if (size < 1 || size > 32)
|
|
140
|
+
throw new Error(`"${text}" is not a valid fixed bytes size; it must be 1 to 32`);
|
|
141
|
+
return {
|
|
142
|
+
base: "bytesN",
|
|
143
|
+
bits: size * 8,
|
|
144
|
+
arrayLength: null,
|
|
145
|
+
child: null,
|
|
146
|
+
components: [],
|
|
147
|
+
dynamic: false,
|
|
148
|
+
canonical: `bytes${size}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`"${text}" is not an ABI type this understands`);
|
|
152
|
+
}
|
|
153
|
+
function hexToBytes(raw, what) {
|
|
154
|
+
const text = raw.startsWith("0x") || raw.startsWith("0X") ? raw.slice(2) : raw;
|
|
155
|
+
if (text.length % 2 !== 0)
|
|
156
|
+
throw new Error(`${what} has an odd number of hex digits`);
|
|
157
|
+
if (!/^[0-9a-fA-F]*$/.test(text))
|
|
158
|
+
throw new Error(`${what} is not hex`);
|
|
159
|
+
const out = new Uint8Array(text.length / 2);
|
|
160
|
+
for (let i = 0; i < out.length; i++)
|
|
161
|
+
out[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** Two's complement into a 32-byte word, big-endian. */
|
|
165
|
+
function wordFromBigInt(value, signed, bits, what) {
|
|
166
|
+
const limit = 1n << BigInt(bits);
|
|
167
|
+
let v = value;
|
|
168
|
+
if (signed) {
|
|
169
|
+
const half = limit >> 1n;
|
|
170
|
+
if (v >= half || v < -half) {
|
|
171
|
+
throw new Error(`${what}: ${value} does not fit in an int${bits}`);
|
|
172
|
+
}
|
|
173
|
+
if (v < 0n)
|
|
174
|
+
v += limit;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
if (v < 0n)
|
|
178
|
+
throw new Error(`${what}: ${value} is negative, and the type is unsigned`);
|
|
179
|
+
if (v >= limit)
|
|
180
|
+
throw new Error(`${what}: ${value} does not fit in a uint${bits}`);
|
|
181
|
+
}
|
|
182
|
+
const out = new Uint8Array(WORD);
|
|
183
|
+
for (let i = WORD - 1; i >= 0 && v > 0n; i--) {
|
|
184
|
+
out[i] = Number(v & 0xffn);
|
|
185
|
+
v >>= 8n;
|
|
186
|
+
}
|
|
187
|
+
// A negative value sign-extends to the full word.
|
|
188
|
+
if (signed && value < 0n) {
|
|
189
|
+
const bytes = bits / 8;
|
|
190
|
+
for (let i = 0; i < WORD - bytes; i++)
|
|
191
|
+
out[i] = 0xff;
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
function toBigInt(value, what) {
|
|
196
|
+
if (typeof value === "bigint")
|
|
197
|
+
return value;
|
|
198
|
+
if (typeof value === "number") {
|
|
199
|
+
if (!Number.isInteger(value))
|
|
200
|
+
throw new Error(`${what}: ${value} is not an integer`);
|
|
201
|
+
if (!Number.isSafeInteger(value)) {
|
|
202
|
+
throw new Error(`${what}: ${value} is past the safe integer range, so it has already lost precision — pass it as a string or a bigint`);
|
|
203
|
+
}
|
|
204
|
+
return BigInt(value);
|
|
205
|
+
}
|
|
206
|
+
if (typeof value === "string") {
|
|
207
|
+
const text = value.trim();
|
|
208
|
+
if (!/^-?(0x[0-9a-fA-F]+|\d+)$/.test(text))
|
|
209
|
+
throw new Error(`${what}: "${value}" is not an integer`);
|
|
210
|
+
return BigInt(text);
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`${what}: expected an integer`);
|
|
213
|
+
}
|
|
214
|
+
const concat = (parts) => {
|
|
215
|
+
const total = parts.reduce((s, p) => s + p.length, 0);
|
|
216
|
+
const out = new Uint8Array(total);
|
|
217
|
+
let at = 0;
|
|
218
|
+
for (const part of parts) {
|
|
219
|
+
out.set(part, at);
|
|
220
|
+
at += part.length;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
};
|
|
224
|
+
const padRight = (bytes) => {
|
|
225
|
+
const padded = new Uint8Array(Math.ceil(bytes.length / WORD) * WORD);
|
|
226
|
+
padded.set(bytes);
|
|
227
|
+
return padded;
|
|
228
|
+
};
|
|
229
|
+
/** Encode one value. Returns head and tail; dynamic values put bytes in the tail. */
|
|
230
|
+
function encodeValue(type, value, what) {
|
|
231
|
+
const none = new Uint8Array(0);
|
|
232
|
+
if (type.base === "array") {
|
|
233
|
+
const child = type.child;
|
|
234
|
+
if (!Array.isArray(value))
|
|
235
|
+
throw new Error(`${what}: expected an array`);
|
|
236
|
+
if (type.arrayLength !== -1 && value.length !== type.arrayLength) {
|
|
237
|
+
throw new Error(`${what}: expected ${type.arrayLength} items, got ${value.length}`);
|
|
238
|
+
}
|
|
239
|
+
const body = encodeTuple(value.map(() => child), value, what);
|
|
240
|
+
if (type.arrayLength === -1) {
|
|
241
|
+
return {
|
|
242
|
+
head: none,
|
|
243
|
+
tail: concat([wordFromBigInt(BigInt(value.length), false, 256, what), body]),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
return type.dynamic ? { head: none, tail: body } : { head: body, tail: none };
|
|
247
|
+
}
|
|
248
|
+
if (type.base === "tuple") {
|
|
249
|
+
if (!Array.isArray(value))
|
|
250
|
+
throw new Error(`${what}: expected an array of components`);
|
|
251
|
+
if (value.length !== type.components.length) {
|
|
252
|
+
throw new Error(`${what}: expected ${type.components.length} components, got ${value.length}`);
|
|
253
|
+
}
|
|
254
|
+
const body = encodeTuple(type.components, value, what);
|
|
255
|
+
return type.dynamic ? { head: none, tail: body } : { head: body, tail: none };
|
|
256
|
+
}
|
|
257
|
+
if (type.base === "string" || type.base === "bytes") {
|
|
258
|
+
const bytes = type.base === "string"
|
|
259
|
+
? new TextEncoder().encode(String(value))
|
|
260
|
+
: hexToBytes(String(value), what);
|
|
261
|
+
return {
|
|
262
|
+
head: none,
|
|
263
|
+
tail: concat([wordFromBigInt(BigInt(bytes.length), false, 256, what), padRight(bytes)]),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (type.base === "address") {
|
|
267
|
+
const bytes = hexToBytes(String(value), what);
|
|
268
|
+
if (bytes.length !== 20)
|
|
269
|
+
throw new Error(`${what}: an address is 20 bytes, got ${bytes.length}`);
|
|
270
|
+
const word = new Uint8Array(WORD);
|
|
271
|
+
word.set(bytes, 12);
|
|
272
|
+
return { head: word, tail: none };
|
|
273
|
+
}
|
|
274
|
+
if (type.base === "bool") {
|
|
275
|
+
if (typeof value !== "boolean")
|
|
276
|
+
throw new Error(`${what}: expected true or false`);
|
|
277
|
+
return { head: wordFromBigInt(value ? 1n : 0n, false, 8, what), tail: none };
|
|
278
|
+
}
|
|
279
|
+
if (type.base === "bytesN") {
|
|
280
|
+
const bytes = hexToBytes(String(value), what);
|
|
281
|
+
const size = type.bits / 8;
|
|
282
|
+
if (bytes.length !== size)
|
|
283
|
+
throw new Error(`${what}: bytes${size} needs ${size} bytes, got ${bytes.length}`);
|
|
284
|
+
// Fixed bytes are LEFT-aligned; integers are right-aligned. Getting this
|
|
285
|
+
// backwards produces a word a contract reads as a completely different
|
|
286
|
+
// value.
|
|
287
|
+
const word = new Uint8Array(WORD);
|
|
288
|
+
word.set(bytes, 0);
|
|
289
|
+
return { head: word, tail: none };
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
head: wordFromBigInt(toBigInt(value, what), type.base === "int", type.bits, what),
|
|
293
|
+
tail: none,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** Head/tail encoding for a list of types, which is what a tuple is. */
|
|
297
|
+
export function encodeTuple(types, values, what = "argument") {
|
|
298
|
+
const parts = types.map((type, i) => encodeValue(type, values[i], `${what}[${i}]`));
|
|
299
|
+
// Each dynamic value occupies one word in the head, holding the offset of
|
|
300
|
+
// its bytes from the start of THIS tuple — not from the start of the call.
|
|
301
|
+
const headSize = types.reduce((sum, type, i) => sum + (type.dynamic ? WORD : parts[i].head.length), 0);
|
|
302
|
+
const heads = [];
|
|
303
|
+
const tails = [];
|
|
304
|
+
let tailOffset = headSize;
|
|
305
|
+
for (const [i, type] of types.entries()) {
|
|
306
|
+
const part = parts[i];
|
|
307
|
+
if (type.dynamic) {
|
|
308
|
+
heads.push(wordFromBigInt(BigInt(tailOffset), false, 256, what));
|
|
309
|
+
tails.push(part.tail);
|
|
310
|
+
tailOffset += part.tail.length;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
heads.push(part.head);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return concat([...heads, ...tails]);
|
|
317
|
+
}
|
|
318
|
+
/** The canonical signature a selector is hashed over. */
|
|
319
|
+
export function canonicalSignature(name, types) {
|
|
320
|
+
return `${name}(${types.map((t) => t.canonical).join(",")})`;
|
|
321
|
+
}
|
|
322
|
+
export function selectorOf(signature) {
|
|
323
|
+
return toHex(keccak256(new TextEncoder().encode(signature))).slice(0, 8);
|
|
324
|
+
}
|
|
325
|
+
/** Split `transfer(address,uint256)` into its name and parsed argument types. */
|
|
326
|
+
export function parseSignature(signature) {
|
|
327
|
+
const open = signature.indexOf("(");
|
|
328
|
+
if (open === -1 || !signature.trim().endsWith(")")) {
|
|
329
|
+
throw new Error(`"${signature}" is not a function signature — expected name(type,type)`);
|
|
330
|
+
}
|
|
331
|
+
const name = signature.slice(0, open).trim();
|
|
332
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
|
333
|
+
throw new Error(`"${name}" is not a valid function name`);
|
|
334
|
+
}
|
|
335
|
+
const inner = signature.slice(open + 1, signature.lastIndexOf(")"));
|
|
336
|
+
return { name, types: inner.trim() === "" ? [] : splitTop(inner).map(parseType) };
|
|
337
|
+
}
|
|
338
|
+
export function encodeCall(signature, args) {
|
|
339
|
+
const { name, types } = parseSignature(signature);
|
|
340
|
+
if (args.length !== types.length) {
|
|
341
|
+
throw new Error(`${name} takes ${types.length} argument(s), ${args.length} given`);
|
|
342
|
+
}
|
|
343
|
+
const canonical = canonicalSignature(name, types);
|
|
344
|
+
return `0x${selectorOf(canonical)}${toHex(encodeTuple(types, args))}`;
|
|
345
|
+
}
|
|
346
|
+
function wordAt(data, offset, what) {
|
|
347
|
+
if (offset + WORD > data.length) {
|
|
348
|
+
throw new Error(`${what}: the data ends before offset ${offset}, so it is truncated or mistyped`);
|
|
349
|
+
}
|
|
350
|
+
return data.subarray(offset, offset + WORD);
|
|
351
|
+
}
|
|
352
|
+
function bigIntFromWord(word, signed, bits) {
|
|
353
|
+
let value = 0n;
|
|
354
|
+
for (const byte of word)
|
|
355
|
+
value = (value << 8n) | BigInt(byte);
|
|
356
|
+
if (!signed)
|
|
357
|
+
return value;
|
|
358
|
+
const limit = 1n << BigInt(bits);
|
|
359
|
+
const half = limit >> 1n;
|
|
360
|
+
const truncated = value & (limit - 1n);
|
|
361
|
+
return truncated >= half ? truncated - limit : truncated;
|
|
362
|
+
}
|
|
363
|
+
function decodeValue(type, data, offset, what) {
|
|
364
|
+
if (type.base === "array") {
|
|
365
|
+
const child = type.child;
|
|
366
|
+
if (type.arrayLength === -1) {
|
|
367
|
+
const length = Number(bigIntFromWord(wordAt(data, offset, what), false, 256));
|
|
368
|
+
if (length * WORD > data.length) {
|
|
369
|
+
throw new Error(`${what}: claims ${length} items, more than the data could hold`);
|
|
370
|
+
}
|
|
371
|
+
return decodeTuple(new Array(length).fill(child), data, offset + WORD, what);
|
|
372
|
+
}
|
|
373
|
+
return decodeTuple(new Array(type.arrayLength).fill(child), data, offset, what);
|
|
374
|
+
}
|
|
375
|
+
if (type.base === "tuple")
|
|
376
|
+
return decodeTuple(type.components, data, offset, what);
|
|
377
|
+
if (type.base === "string" || type.base === "bytes") {
|
|
378
|
+
const length = Number(bigIntFromWord(wordAt(data, offset, what), false, 256));
|
|
379
|
+
const start = offset + WORD;
|
|
380
|
+
if (start + length > data.length) {
|
|
381
|
+
throw new Error(`${what}: declares ${length} bytes, past the end of the data`);
|
|
382
|
+
}
|
|
383
|
+
const bytes = data.subarray(start, start + length);
|
|
384
|
+
return type.base === "string" ? new TextDecoder().decode(bytes) : `0x${toHex(bytes)}`;
|
|
385
|
+
}
|
|
386
|
+
const word = wordAt(data, offset, what);
|
|
387
|
+
if (type.base === "address")
|
|
388
|
+
return `0x${toHex(word.subarray(12))}`;
|
|
389
|
+
if (type.base === "bool") {
|
|
390
|
+
const value = bigIntFromWord(word, false, 256);
|
|
391
|
+
if (value > 1n)
|
|
392
|
+
throw new Error(`${what}: a bool word holds ${value}, which is neither true nor false`);
|
|
393
|
+
return value === 1n;
|
|
394
|
+
}
|
|
395
|
+
if (type.base === "bytesN")
|
|
396
|
+
return `0x${toHex(word.subarray(0, type.bits / 8))}`;
|
|
397
|
+
return bigIntFromWord(word, type.base === "int", type.bits).toString();
|
|
398
|
+
}
|
|
399
|
+
export function decodeTuple(types, data, base = 0, what = "value") {
|
|
400
|
+
const out = [];
|
|
401
|
+
let head = base;
|
|
402
|
+
for (const [i, type] of types.entries()) {
|
|
403
|
+
const label = `${what}[${i}]`;
|
|
404
|
+
if (type.dynamic) {
|
|
405
|
+
const offset = Number(bigIntFromWord(wordAt(data, head, label), false, 256));
|
|
406
|
+
// An offset is relative to the start of the enclosing tuple. Treating
|
|
407
|
+
// it as absolute reads the wrong bytes and usually still "works".
|
|
408
|
+
out.push(decodeValue(type, data, base + offset, label));
|
|
409
|
+
head += WORD;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
out.push(decodeValue(type, data, head, label));
|
|
413
|
+
head += staticSize(type);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
/** How many bytes a static type occupies in a head. */
|
|
419
|
+
function staticSize(type) {
|
|
420
|
+
if (type.base === "tuple")
|
|
421
|
+
return type.components.reduce((s, c) => s + staticSize(c), 0);
|
|
422
|
+
if (type.base === "array")
|
|
423
|
+
return type.arrayLength * staticSize(type.child);
|
|
424
|
+
return WORD;
|
|
425
|
+
}
|
|
426
|
+
export function decodeData(typeList, hex) {
|
|
427
|
+
const types = typeList.map(parseType);
|
|
428
|
+
return decodeTuple(types, hexToBytes(hex, "data"));
|
|
429
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type AddressResult = {
|
|
2
|
+
readonly valid: boolean;
|
|
3
|
+
/** The EIP-55 mixed-case form, when the input was a well-formed address. */
|
|
4
|
+
readonly checksummed: string;
|
|
5
|
+
readonly lowercase: string;
|
|
6
|
+
/** True when the input carried a checksum that this verified. */
|
|
7
|
+
readonly hadChecksum: boolean;
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
readonly isZero: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** Apply the EIP-55 case checksum to a lowercase address. */
|
|
12
|
+
export declare function toChecksumAddress(lowercase: string): string;
|
|
13
|
+
export declare function validateAddress(raw: string): AddressResult;
|
|
14
|
+
/**
|
|
15
|
+
* Parse a decimal amount into base units, exactly.
|
|
16
|
+
*
|
|
17
|
+
* `parseFloat("0.1") * 1e18` is not 10^17, and the difference is real money.
|
|
18
|
+
* This is string arithmetic: no value ever becomes a double.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseUnits(amount: string, decimals: number): bigint;
|
|
21
|
+
/** Render base units as a decimal string, exactly, with no trailing zeros. */
|
|
22
|
+
export declare function formatUnits(value: bigint, decimals: number): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-55 address checksums and unit conversion.
|
|
3
|
+
*
|
|
4
|
+
* An Ethereum address has no check digits of its own — it is 20 raw bytes,
|
|
5
|
+
* and any 20 bytes are a valid address. EIP-55 adds a checksum by varying
|
|
6
|
+
* the CASE of the hex, which is why a mixed-case address can be validated
|
|
7
|
+
* and an all-lowercase one cannot. Sending to a mistyped address is
|
|
8
|
+
* irreversible, so this is the one check available and it is worth making.
|
|
9
|
+
*/
|
|
10
|
+
import { keccak256Hex } from "@crewhaus/tool-encode";
|
|
11
|
+
/** Apply the EIP-55 case checksum to a lowercase address. */
|
|
12
|
+
export function toChecksumAddress(lowercase) {
|
|
13
|
+
const body = lowercase.replace(/^0x/i, "").toLowerCase();
|
|
14
|
+
const hash = keccak256Hex(body);
|
|
15
|
+
let out = "0x";
|
|
16
|
+
for (let i = 0; i < body.length; i++) {
|
|
17
|
+
const char = body[i];
|
|
18
|
+
// A nibble of 8 or more uppercases the corresponding hex digit; digits
|
|
19
|
+
// have no case, so they are unaffected.
|
|
20
|
+
out += Number.parseInt(hash[i], 16) >= 8 ? char.toUpperCase() : char;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function validateAddress(raw) {
|
|
25
|
+
const text = raw.trim();
|
|
26
|
+
const body = text.replace(/^0x/i, "");
|
|
27
|
+
const base = { lowercase: `0x${body.toLowerCase()}`, isZero: /^0*$/.test(body) };
|
|
28
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(text)) {
|
|
29
|
+
return {
|
|
30
|
+
...base,
|
|
31
|
+
valid: false,
|
|
32
|
+
checksummed: "",
|
|
33
|
+
hadChecksum: false,
|
|
34
|
+
reason: /^0x/i.test(text)
|
|
35
|
+
? `an address is 0x followed by 40 hex characters; this has ${body.length}`
|
|
36
|
+
: "an address starts with 0x",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const checksummed = toChecksumAddress(body);
|
|
40
|
+
const mixed = body !== body.toLowerCase() && body !== body.toUpperCase();
|
|
41
|
+
if (mixed && `0x${body}` !== checksummed) {
|
|
42
|
+
return {
|
|
43
|
+
...base,
|
|
44
|
+
valid: false,
|
|
45
|
+
checksummed,
|
|
46
|
+
hadChecksum: true,
|
|
47
|
+
// This is the case worth catching: the address is well-formed and the
|
|
48
|
+
// checksum says at least one character is wrong.
|
|
49
|
+
reason: "the EIP-55 checksum does not match — at least one character is wrong",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...base,
|
|
54
|
+
valid: true,
|
|
55
|
+
checksummed,
|
|
56
|
+
hadChecksum: mixed,
|
|
57
|
+
reason: mixed
|
|
58
|
+
? ""
|
|
59
|
+
: "this address carries no EIP-55 checksum, so only its shape was checked; a typo in an all-lowercase address cannot be detected",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse a decimal amount into base units, exactly.
|
|
64
|
+
*
|
|
65
|
+
* `parseFloat("0.1") * 1e18` is not 10^17, and the difference is real money.
|
|
66
|
+
* This is string arithmetic: no value ever becomes a double.
|
|
67
|
+
*/
|
|
68
|
+
export function parseUnits(amount, decimals) {
|
|
69
|
+
const text = amount.trim();
|
|
70
|
+
if (!/^-?\d*\.?\d*$/.test(text) || text === "" || text === "." || text === "-") {
|
|
71
|
+
throw new Error(`"${amount}" is not a decimal amount`);
|
|
72
|
+
}
|
|
73
|
+
const negative = text.startsWith("-");
|
|
74
|
+
const unsigned = negative ? text.slice(1) : text;
|
|
75
|
+
const [whole = "", fraction = ""] = unsigned.split(".");
|
|
76
|
+
if (fraction.length > decimals) {
|
|
77
|
+
throw new Error(`"${amount}" has ${fraction.length} decimal places but the unit has ${decimals}; the extra digits would be silently dropped`);
|
|
78
|
+
}
|
|
79
|
+
const scaled = `${whole === "" ? "0" : whole}${fraction.padEnd(decimals, "0")}`;
|
|
80
|
+
const value = BigInt(scaled === "" ? "0" : scaled);
|
|
81
|
+
return negative ? -value : value;
|
|
82
|
+
}
|
|
83
|
+
/** Render base units as a decimal string, exactly, with no trailing zeros. */
|
|
84
|
+
export function formatUnits(value, decimals) {
|
|
85
|
+
const negative = value < 0n;
|
|
86
|
+
const digits = (negative ? -value : value).toString().padStart(decimals + 1, "0");
|
|
87
|
+
const whole = digits.slice(0, digits.length - decimals);
|
|
88
|
+
const fraction = decimals === 0 ? "" : digits.slice(digits.length - decimals).replace(/0+$/, "");
|
|
89
|
+
return `${negative ? "-" : ""}${whole}${fraction === "" ? "" : `.${fraction}`}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed-point arithmetic that goes wrong in a model's head.
|
|
3
|
+
*
|
|
4
|
+
* Every value here is an integer in base units. Slippage is basis points,
|
|
5
|
+
* not a percentage, because a percentage invites a decimal and a decimal
|
|
6
|
+
* invites a double. A `minOut` that is off by a rounding error either fails
|
|
7
|
+
* a swap that should have succeeded or accepts one that should have failed.
|
|
8
|
+
*/
|
|
9
|
+
export type SlippageResult = {
|
|
10
|
+
readonly quotedOut: string;
|
|
11
|
+
readonly slippageBps: number;
|
|
12
|
+
readonly minOut: string;
|
|
13
|
+
/** What the caller gives up in the worst allowed case. */
|
|
14
|
+
readonly worstCaseLossBase: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The floor a swap may return before it should revert.
|
|
18
|
+
*
|
|
19
|
+
* Rounded DOWN, always. Rounding a minimum up would reject swaps that were
|
|
20
|
+
* inside the caller's tolerance, and the direction of a rounding error in a
|
|
21
|
+
* bound is not a detail.
|
|
22
|
+
*/
|
|
23
|
+
export declare function minimumOut(quotedOut: bigint, slippageBps: number): SlippageResult;
|
|
24
|
+
/** The ceiling an input may reach, for an exact-output swap. Rounded UP. */
|
|
25
|
+
export declare function maximumIn(quotedIn: bigint, slippageBps: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* Convert an amount between two token decimalisations.
|
|
28
|
+
*
|
|
29
|
+
* Six-decimal USDC and eighteen-decimal DAI hold "the same" amount as very
|
|
30
|
+
* different integers, and mixing them up is a factor of a trillion.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rescaleDecimals(amount: bigint, from: number, to: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Price impact in basis points, against a reference mid price.
|
|
35
|
+
*
|
|
36
|
+
* Both prices are integers with the same scale. Positive means the execution
|
|
37
|
+
* is worse than the reference.
|
|
38
|
+
*/
|
|
39
|
+
export declare function priceImpactBps(executionPrice: bigint, referencePrice: bigint): number;
|
|
40
|
+
/** Proportional share of a pool, in basis points, for an LP position. */
|
|
41
|
+
export declare function shareBps(part: bigint, total: bigint): number;
|
|
42
|
+
/**
|
|
43
|
+
* Health factor for a collateralised position, in basis points.
|
|
44
|
+
*
|
|
45
|
+
* `collateral * liquidationThresholdBps / debt`. Below 10000 the position is
|
|
46
|
+
* liquidatable. Debt of zero has no health factor rather than an infinite
|
|
47
|
+
* one, because a caller comparing against a threshold would read Infinity as
|
|
48
|
+
* safe and a NaN as unsafe by accident.
|
|
49
|
+
*/
|
|
50
|
+
export declare function healthFactorBps(collateralBase: bigint, debtBase: bigint, liquidationThresholdBps: number): number | null;
|
package/dist/lib/defi.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed-point arithmetic that goes wrong in a model's head.
|
|
3
|
+
*
|
|
4
|
+
* Every value here is an integer in base units. Slippage is basis points,
|
|
5
|
+
* not a percentage, because a percentage invites a decimal and a decimal
|
|
6
|
+
* invites a double. A `minOut` that is off by a rounding error either fails
|
|
7
|
+
* a swap that should have succeeded or accepts one that should have failed.
|
|
8
|
+
*/
|
|
9
|
+
const BPS = 10000n;
|
|
10
|
+
/**
|
|
11
|
+
* The floor a swap may return before it should revert.
|
|
12
|
+
*
|
|
13
|
+
* Rounded DOWN, always. Rounding a minimum up would reject swaps that were
|
|
14
|
+
* inside the caller's tolerance, and the direction of a rounding error in a
|
|
15
|
+
* bound is not a detail.
|
|
16
|
+
*/
|
|
17
|
+
export function minimumOut(quotedOut, slippageBps) {
|
|
18
|
+
if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 10_000) {
|
|
19
|
+
throw new Error(`slippageBps must be an integer from 0 to 10000, got ${slippageBps}`);
|
|
20
|
+
}
|
|
21
|
+
if (quotedOut < 0n)
|
|
22
|
+
throw new Error("quotedOut must not be negative");
|
|
23
|
+
const minOut = (quotedOut * (BPS - BigInt(slippageBps))) / BPS;
|
|
24
|
+
return {
|
|
25
|
+
quotedOut: quotedOut.toString(),
|
|
26
|
+
slippageBps,
|
|
27
|
+
minOut: minOut.toString(),
|
|
28
|
+
worstCaseLossBase: (quotedOut - minOut).toString(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The ceiling an input may reach, for an exact-output swap. Rounded UP. */
|
|
32
|
+
export function maximumIn(quotedIn, slippageBps) {
|
|
33
|
+
if (!Number.isInteger(slippageBps) || slippageBps < 0 || slippageBps > 10_000) {
|
|
34
|
+
throw new Error(`slippageBps must be an integer from 0 to 10000, got ${slippageBps}`);
|
|
35
|
+
}
|
|
36
|
+
const numerator = quotedIn * (BPS + BigInt(slippageBps));
|
|
37
|
+
// Ceiling division: a maximum rounded down would reject an acceptable swap.
|
|
38
|
+
return ((numerator + BPS - 1n) / BPS).toString();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Convert an amount between two token decimalisations.
|
|
42
|
+
*
|
|
43
|
+
* Six-decimal USDC and eighteen-decimal DAI hold "the same" amount as very
|
|
44
|
+
* different integers, and mixing them up is a factor of a trillion.
|
|
45
|
+
*/
|
|
46
|
+
export function rescaleDecimals(amount, from, to) {
|
|
47
|
+
if (from < 0 || to < 0 || from > 77 || to > 77)
|
|
48
|
+
throw new Error("decimals must be 0 to 77");
|
|
49
|
+
if (to >= from)
|
|
50
|
+
return (amount * 10n ** BigInt(to - from)).toString();
|
|
51
|
+
const divisor = 10n ** BigInt(from - to);
|
|
52
|
+
const scaled = amount / divisor;
|
|
53
|
+
if (amount % divisor !== 0n) {
|
|
54
|
+
// Say so rather than silently truncating: going from 18 decimals to 6
|
|
55
|
+
// throws away real value, and the caller has to decide.
|
|
56
|
+
throw new Error(`${amount} at ${from} decimals does not fit in ${to} decimals without discarding ${amount % divisor}; rescale a rounded amount deliberately`);
|
|
57
|
+
}
|
|
58
|
+
return scaled.toString();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Price impact in basis points, against a reference mid price.
|
|
62
|
+
*
|
|
63
|
+
* Both prices are integers with the same scale. Positive means the execution
|
|
64
|
+
* is worse than the reference.
|
|
65
|
+
*/
|
|
66
|
+
export function priceImpactBps(executionPrice, referencePrice) {
|
|
67
|
+
if (referencePrice <= 0n)
|
|
68
|
+
throw new Error("referencePrice must be positive");
|
|
69
|
+
const delta = referencePrice - executionPrice;
|
|
70
|
+
return Number((delta * BPS) / referencePrice);
|
|
71
|
+
}
|
|
72
|
+
/** Proportional share of a pool, in basis points, for an LP position. */
|
|
73
|
+
export function shareBps(part, total) {
|
|
74
|
+
if (total <= 0n)
|
|
75
|
+
throw new Error("total must be positive");
|
|
76
|
+
if (part < 0n)
|
|
77
|
+
throw new Error("part must not be negative");
|
|
78
|
+
return Number((part * BPS) / total);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Health factor for a collateralised position, in basis points.
|
|
82
|
+
*
|
|
83
|
+
* `collateral * liquidationThresholdBps / debt`. Below 10000 the position is
|
|
84
|
+
* liquidatable. Debt of zero has no health factor rather than an infinite
|
|
85
|
+
* one, because a caller comparing against a threshold would read Infinity as
|
|
86
|
+
* safe and a NaN as unsafe by accident.
|
|
87
|
+
*/
|
|
88
|
+
export function healthFactorBps(collateralBase, debtBase, liquidationThresholdBps) {
|
|
89
|
+
if (debtBase <= 0n)
|
|
90
|
+
return null;
|
|
91
|
+
if (collateralBase < 0n)
|
|
92
|
+
throw new Error("collateral must not be negative");
|
|
93
|
+
return Number((collateralBase * BigInt(liquidationThresholdBps)) / debtBase);
|
|
94
|
+
}
|