@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Multicall3 is deployed on most chains, via a deterministic deploy.
|
|
3
|
+
*
|
|
4
|
+
* It is a DEFAULT, never a constant baked into the calldata path: the deploy
|
|
5
|
+
* is only canonical where somebody ran it, and a chain that predates it, an
|
|
6
|
+
* L2 with its own deployment, or a local fork can all have it elsewhere.
|
|
7
|
+
* Every entry point here takes the address as an argument.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
10
|
+
/** The canonical signature, which is what the selector is hashed over. */
|
|
11
|
+
export declare const AGGREGATE3_SIGNATURE = "aggregate3((address,bool,bytes)[])";
|
|
12
|
+
/** `0x82ad56cb` — the four bytes a block explorer shows for a Multicall3 batch. */
|
|
13
|
+
export declare const AGGREGATE3_SELECTOR = "0x82ad56cb";
|
|
14
|
+
/** What `aggregate3` returns: one `(bool success, bytes returnData)` per call. */
|
|
15
|
+
export declare const AGGREGATE3_RETURN_TYPE = "(bool,bytes)[]";
|
|
16
|
+
/** `Error(string)` — a `revert("...")` or a failed `require` with a message. */
|
|
17
|
+
export declare const ERROR_STRING_SELECTOR = "0x08c379a0";
|
|
18
|
+
/** `Panic(uint256)` — a failed `assert`, an overflow, a bad index. */
|
|
19
|
+
export declare const PANIC_SELECTOR = "0x4e487b71";
|
|
20
|
+
/**
|
|
21
|
+
* The panic codes Solidity documents. A code outside this table decodes to a
|
|
22
|
+
* code with no reason attached rather than to a guess.
|
|
23
|
+
*/
|
|
24
|
+
export declare const PANIC_REASONS: Readonly<Record<string, string>>;
|
|
25
|
+
/** One call in a batch. */
|
|
26
|
+
export type Call3 = {
|
|
27
|
+
/** The contract to call. */
|
|
28
|
+
readonly target: string;
|
|
29
|
+
/** Its calldata, 0x hex. `0x` is legal and reaches the fallback. */
|
|
30
|
+
readonly callData: string;
|
|
31
|
+
/**
|
|
32
|
+
* Whether this call reverting is allowed to be data.
|
|
33
|
+
*
|
|
34
|
+
* Omitted means TRUE, which is the point of `aggregate3`: the batch still
|
|
35
|
+
* succeeds and this entry carries `success: false` with its revert bytes.
|
|
36
|
+
* Set it false only when this call failing should take the whole batch
|
|
37
|
+
* down with it — then there are no partial results to read at all.
|
|
38
|
+
*/
|
|
39
|
+
readonly allowFailure?: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** A packed batch: what to send, and where. */
|
|
42
|
+
export type Aggregate3Request = {
|
|
43
|
+
/** The Multicall3 address the calldata is meant for, EIP-55 checksummed. */
|
|
44
|
+
readonly to: string;
|
|
45
|
+
/** The calldata, 0x hex. */
|
|
46
|
+
readonly data: string;
|
|
47
|
+
readonly selector: string;
|
|
48
|
+
/** How many results the answer must hold. Decoding checks it. */
|
|
49
|
+
readonly callCount: number;
|
|
50
|
+
};
|
|
51
|
+
/** What a sub-call's revert bytes turned out to say. */
|
|
52
|
+
export type RevertKind = "none" | "string" | "panic" | "unknown";
|
|
53
|
+
export type RevertReason = {
|
|
54
|
+
readonly kind: RevertKind;
|
|
55
|
+
/** The decoded reason, or null when the bytes do not carry one. Never invented. */
|
|
56
|
+
readonly reason: string | null;
|
|
57
|
+
/** The four-byte error selector, when the data is long enough to have one. */
|
|
58
|
+
readonly selector: string | null;
|
|
59
|
+
/** The panic code as hex, e.g. `0x11`. Panics only. */
|
|
60
|
+
readonly panicCode: string | null;
|
|
61
|
+
/** The raw bytes, always, so an unrecognised custom error is still actionable. */
|
|
62
|
+
readonly data: string;
|
|
63
|
+
};
|
|
64
|
+
/** One call's answer, at the position of the call that produced it. */
|
|
65
|
+
export type Aggregate3Result = {
|
|
66
|
+
/** The index of the call in the batch that was sent. */
|
|
67
|
+
readonly index: number;
|
|
68
|
+
readonly success: boolean;
|
|
69
|
+
/** Return data on success, revert data on failure. Both are 0x hex. */
|
|
70
|
+
readonly returnData: string;
|
|
71
|
+
/** The revert, decoded, when this call failed. Null when it succeeded. */
|
|
72
|
+
readonly revert: RevertReason | null;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Pack a batch into `aggregate3` calldata.
|
|
76
|
+
*
|
|
77
|
+
* `multicall3Address` defaults to {@link MULTICALL3_ADDRESS} and is returned
|
|
78
|
+
* as `to`; pass a chain's own deployment when it has one. An empty batch is
|
|
79
|
+
* legal and encodes to an empty array — a caller assembling a batch from a
|
|
80
|
+
* filtered list should not have to special-case the day the filter matches
|
|
81
|
+
* nothing.
|
|
82
|
+
*/
|
|
83
|
+
export declare function encodeAggregate3(calls: ReadonlyArray<Call3>, multicall3Address?: string): Aggregate3Request;
|
|
84
|
+
/**
|
|
85
|
+
* Decode `(bool,bytes)[]` back into per-call results, in the order sent.
|
|
86
|
+
*
|
|
87
|
+
* `expectedCalls` is the length of the batch that produced this blob, and a
|
|
88
|
+
* mismatch throws: the results carry no identity of their own, so pairing a
|
|
89
|
+
* short answer with the calls that were sent silently attributes each result
|
|
90
|
+
* to the wrong call.
|
|
91
|
+
*
|
|
92
|
+
* A sub-call that reverted is NOT an exception — it is a row with
|
|
93
|
+
* `success: false`. What throws is the batch's own answer being unreadable,
|
|
94
|
+
* which is a different event with a different fix.
|
|
95
|
+
*/
|
|
96
|
+
export declare function decodeAggregate3(returnData: string, expectedCalls: number): ReadonlyArray<Aggregate3Result>;
|
|
97
|
+
/**
|
|
98
|
+
* Read revert bytes as the reason they carry.
|
|
99
|
+
*
|
|
100
|
+
* `Error(string)` and `Panic(uint256)` have known selectors and decode to
|
|
101
|
+
* something a human can act on. Anything else — a custom error, four bytes
|
|
102
|
+
* of nothing in particular — keeps its hex and gets no message: a guess at
|
|
103
|
+
* what an unknown selector means is worse than the hex, because the hex is
|
|
104
|
+
* at least searchable.
|
|
105
|
+
*/
|
|
106
|
+
export declare function decodeRevertData(revertData: string): RevertReason;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multicall3 request packing and result unpacking.
|
|
3
|
+
*
|
|
4
|
+
* One `eth_call` against Multicall3 answers a hundred questions at one block
|
|
5
|
+
* height, which is the only way to read a hundred balances that are
|
|
6
|
+
* consistent with each other. The packing is where it goes wrong: the
|
|
7
|
+
* argument is an array of dynamic tuples, so every element leaves an offset
|
|
8
|
+
* behind — relative to the start of the array's DATA, not to the call — and
|
|
9
|
+
* a wrong offset produces calldata a node accepts and the contract misreads.
|
|
10
|
+
*
|
|
11
|
+
* Two things this is careful about, because both are how a batch read turns
|
|
12
|
+
* into a wrong number rather than an error:
|
|
13
|
+
*
|
|
14
|
+
* 1. **A failed sub-call is data.** With `allowFailure` set, the batch
|
|
15
|
+
* succeeds and the failed entry carries `success: false` and its revert
|
|
16
|
+
* bytes. That is different from the batch itself failing, and the two
|
|
17
|
+
* must not arrive at a caller looking the same: a sub-call revert comes
|
|
18
|
+
* back as a row, a blob this cannot read is thrown.
|
|
19
|
+
* 2. **Results are matched by POSITION.** Nothing in the return blob names
|
|
20
|
+
* the call it answers. A blob holding a different number of results than
|
|
21
|
+
* the batch sent is therefore refused rather than zipped up to the
|
|
22
|
+
* shorter of the two, because a balance read against the wrong token is
|
|
23
|
+
* worse than no balance at all.
|
|
24
|
+
*
|
|
25
|
+
* Pure, like the rest of this package: this builds the bytes of a read and
|
|
26
|
+
* reads the bytes back. It does not dial anything, and nothing here composes
|
|
27
|
+
* or submits a transaction.
|
|
28
|
+
*/
|
|
29
|
+
import { decodeData, encodeCall } from "./abi";
|
|
30
|
+
import { validateAddress } from "./address";
|
|
31
|
+
/**
|
|
32
|
+
* Where Multicall3 is deployed on most chains, via a deterministic deploy.
|
|
33
|
+
*
|
|
34
|
+
* It is a DEFAULT, never a constant baked into the calldata path: the deploy
|
|
35
|
+
* is only canonical where somebody ran it, and a chain that predates it, an
|
|
36
|
+
* L2 with its own deployment, or a local fork can all have it elsewhere.
|
|
37
|
+
* Every entry point here takes the address as an argument.
|
|
38
|
+
*/
|
|
39
|
+
export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
|
|
40
|
+
/** The canonical signature, which is what the selector is hashed over. */
|
|
41
|
+
export const AGGREGATE3_SIGNATURE = "aggregate3((address,bool,bytes)[])";
|
|
42
|
+
/** `0x82ad56cb` — the four bytes a block explorer shows for a Multicall3 batch. */
|
|
43
|
+
export const AGGREGATE3_SELECTOR = "0x82ad56cb";
|
|
44
|
+
/** What `aggregate3` returns: one `(bool success, bytes returnData)` per call. */
|
|
45
|
+
export const AGGREGATE3_RETURN_TYPE = "(bool,bytes)[]";
|
|
46
|
+
/** `Error(string)` — a `revert("...")` or a failed `require` with a message. */
|
|
47
|
+
export const ERROR_STRING_SELECTOR = "0x08c379a0";
|
|
48
|
+
/** `Panic(uint256)` — a failed `assert`, an overflow, a bad index. */
|
|
49
|
+
export const PANIC_SELECTOR = "0x4e487b71";
|
|
50
|
+
/**
|
|
51
|
+
* The panic codes Solidity documents. A code outside this table decodes to a
|
|
52
|
+
* code with no reason attached rather than to a guess.
|
|
53
|
+
*/
|
|
54
|
+
export const PANIC_REASONS = Object.freeze({
|
|
55
|
+
"0x00": "a generic compiler-inserted panic",
|
|
56
|
+
"0x01": "an assert argument evaluated to false",
|
|
57
|
+
"0x11": "arithmetic overflowed or underflowed outside an unchecked block",
|
|
58
|
+
"0x12": "division or modulo by zero",
|
|
59
|
+
"0x21": "a value was converted into an enum that has no such member",
|
|
60
|
+
"0x22": "a storage byte array is incorrectly encoded",
|
|
61
|
+
"0x31": "pop() on an empty array",
|
|
62
|
+
"0x32": "an array index is out of bounds or negative",
|
|
63
|
+
"0x41": "too much memory was allocated, or an array was created too large",
|
|
64
|
+
"0x51": "a zero-initialised variable of internal function type was called",
|
|
65
|
+
});
|
|
66
|
+
function checkedAddress(raw, what) {
|
|
67
|
+
const result = validateAddress(raw);
|
|
68
|
+
if (!result.valid)
|
|
69
|
+
throw new Error(`${what}: ${result.reason}`);
|
|
70
|
+
return result.checksummed;
|
|
71
|
+
}
|
|
72
|
+
function checkedBytes(raw, what) {
|
|
73
|
+
const text = raw.trim();
|
|
74
|
+
// An odd number of hex digits is a truncated paste, and the ABI coder would
|
|
75
|
+
// otherwise report it as an argument problem several frames from here.
|
|
76
|
+
if (!/^0x([0-9a-fA-F]{2})*$/.test(text)) {
|
|
77
|
+
throw new Error(`${what}: expected 0x followed by an even number of hex digits, got "${raw}"`);
|
|
78
|
+
}
|
|
79
|
+
return text.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Pack a batch into `aggregate3` calldata.
|
|
83
|
+
*
|
|
84
|
+
* `multicall3Address` defaults to {@link MULTICALL3_ADDRESS} and is returned
|
|
85
|
+
* as `to`; pass a chain's own deployment when it has one. An empty batch is
|
|
86
|
+
* legal and encodes to an empty array — a caller assembling a batch from a
|
|
87
|
+
* filtered list should not have to special-case the day the filter matches
|
|
88
|
+
* nothing.
|
|
89
|
+
*/
|
|
90
|
+
export function encodeAggregate3(calls, multicall3Address = MULTICALL3_ADDRESS) {
|
|
91
|
+
const to = checkedAddress(multicall3Address, "the Multicall3 address");
|
|
92
|
+
const tuples = calls.map((call, i) => [
|
|
93
|
+
checkedAddress(call.target, `call[${i}].target`),
|
|
94
|
+
call.allowFailure ?? true,
|
|
95
|
+
checkedBytes(call.callData, `call[${i}].callData`),
|
|
96
|
+
]);
|
|
97
|
+
return {
|
|
98
|
+
to,
|
|
99
|
+
data: encodeCall(AGGREGATE3_SIGNATURE, [tuples]),
|
|
100
|
+
selector: AGGREGATE3_SELECTOR,
|
|
101
|
+
callCount: calls.length,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Decode `(bool,bytes)[]` back into per-call results, in the order sent.
|
|
106
|
+
*
|
|
107
|
+
* `expectedCalls` is the length of the batch that produced this blob, and a
|
|
108
|
+
* mismatch throws: the results carry no identity of their own, so pairing a
|
|
109
|
+
* short answer with the calls that were sent silently attributes each result
|
|
110
|
+
* to the wrong call.
|
|
111
|
+
*
|
|
112
|
+
* A sub-call that reverted is NOT an exception — it is a row with
|
|
113
|
+
* `success: false`. What throws is the batch's own answer being unreadable,
|
|
114
|
+
* which is a different event with a different fix.
|
|
115
|
+
*/
|
|
116
|
+
export function decodeAggregate3(returnData, expectedCalls) {
|
|
117
|
+
if (!Number.isInteger(expectedCalls) || expectedCalls < 0) {
|
|
118
|
+
throw new Error(`expectedCalls must be a non-negative integer, got ${expectedCalls}`);
|
|
119
|
+
}
|
|
120
|
+
let rows;
|
|
121
|
+
try {
|
|
122
|
+
[rows] = decodeData([AGGREGATE3_RETURN_TYPE], returnData);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// Said as a batch-level failure on purpose: an unreadable blob means the
|
|
126
|
+
// aggregate call itself did not return what Multicall3 returns — a wrong
|
|
127
|
+
// address, a reverted batch, an empty answer from an address with no code
|
|
128
|
+
// — and none of those are a sub-call reverting.
|
|
129
|
+
throw new Error(`the batch's own return data is not ${AGGREGATE3_RETURN_TYPE}, so no sub-call result can be read from it: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(rows)) {
|
|
132
|
+
throw new Error("the batch's own return data did not decode to an array of results");
|
|
133
|
+
}
|
|
134
|
+
if (rows.length !== expectedCalls) {
|
|
135
|
+
throw new Error(`the batch returned ${rows.length} result(s) for ${expectedCalls} call(s) — refusing to pair them up, because results are matched by position and nothing in them names the call they answer`);
|
|
136
|
+
}
|
|
137
|
+
return rows.map((row, index) => {
|
|
138
|
+
const [success, bytes] = Array.isArray(row) ? row : [];
|
|
139
|
+
if (typeof success !== "boolean" || typeof bytes !== "string") {
|
|
140
|
+
throw new Error(`result ${index} is not a (bool, bytes) pair`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
index,
|
|
144
|
+
success,
|
|
145
|
+
returnData: bytes,
|
|
146
|
+
revert: success ? null : decodeRevertData(bytes),
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Read revert bytes as the reason they carry.
|
|
152
|
+
*
|
|
153
|
+
* `Error(string)` and `Panic(uint256)` have known selectors and decode to
|
|
154
|
+
* something a human can act on. Anything else — a custom error, four bytes
|
|
155
|
+
* of nothing in particular — keeps its hex and gets no message: a guess at
|
|
156
|
+
* what an unknown selector means is worse than the hex, because the hex is
|
|
157
|
+
* at least searchable.
|
|
158
|
+
*/
|
|
159
|
+
export function decodeRevertData(revertData) {
|
|
160
|
+
const data = checkedBytes(revertData, "revert data");
|
|
161
|
+
const byteLength = (data.length - 2) / 2;
|
|
162
|
+
const plain = { reason: null, selector: null, panicCode: null, data };
|
|
163
|
+
// A bare `revert()`, a failed transfer to an address with no code, or a
|
|
164
|
+
// call that ran out of gas all come back empty. There is no message to be
|
|
165
|
+
// had, and "reverted" is not one.
|
|
166
|
+
if (byteLength === 0)
|
|
167
|
+
return { kind: "none", ...plain };
|
|
168
|
+
if (byteLength < 4)
|
|
169
|
+
return { kind: "unknown", ...plain };
|
|
170
|
+
const selector = data.slice(0, 10);
|
|
171
|
+
const payload = `0x${data.slice(10)}`;
|
|
172
|
+
const unknown = { kind: "unknown", reason: null, selector, panicCode: null, data };
|
|
173
|
+
if (selector === ERROR_STRING_SELECTOR) {
|
|
174
|
+
try {
|
|
175
|
+
const [message] = decodeData(["string"], payload);
|
|
176
|
+
if (typeof message === "string") {
|
|
177
|
+
return { kind: "string", reason: message, selector, panicCode: null, data };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// A payload that does not decode falls through to the hex. Throwing
|
|
182
|
+
// here would lose the other results in the same batch over one
|
|
183
|
+
// contract's malformed revert.
|
|
184
|
+
}
|
|
185
|
+
return unknown;
|
|
186
|
+
}
|
|
187
|
+
if (selector === PANIC_SELECTOR) {
|
|
188
|
+
try {
|
|
189
|
+
const [code] = decodeData(["uint256"], payload);
|
|
190
|
+
if (typeof code === "string") {
|
|
191
|
+
const panicCode = `0x${BigInt(code).toString(16).padStart(2, "0")}`;
|
|
192
|
+
return {
|
|
193
|
+
kind: "panic",
|
|
194
|
+
reason: PANIC_REASONS[panicCode] ?? null,
|
|
195
|
+
selector,
|
|
196
|
+
panicCode,
|
|
197
|
+
data,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// As above: unreadable bytes stay bytes.
|
|
203
|
+
}
|
|
204
|
+
return unknown;
|
|
205
|
+
}
|
|
206
|
+
return unknown;
|
|
207
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type TypedField = {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly type: string;
|
|
4
|
+
};
|
|
5
|
+
export type TypedTypes = Readonly<Record<string, ReadonlyArray<TypedField>>>;
|
|
6
|
+
/**
|
|
7
|
+
* `encodeType`: the primary struct first, then every referenced struct in
|
|
8
|
+
* alphabetical order. The order is part of the standard — a different order
|
|
9
|
+
* gives a different type hash and therefore a signature no verifier accepts.
|
|
10
|
+
*/
|
|
11
|
+
export declare function encodeType(primary: string, types: TypedTypes): string;
|
|
12
|
+
export declare function typeHash(primary: string, types: TypedTypes): Uint8Array;
|
|
13
|
+
export declare function hashStruct(primary: string, data: Record<string, unknown>, types: TypedTypes): Uint8Array;
|
|
14
|
+
export type Domain = {
|
|
15
|
+
readonly name?: string;
|
|
16
|
+
readonly version?: string;
|
|
17
|
+
readonly chainId?: number | string;
|
|
18
|
+
readonly verifyingContract?: string;
|
|
19
|
+
readonly salt?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The domain separator.
|
|
23
|
+
*
|
|
24
|
+
* Only the fields actually present are included, in the standard's order.
|
|
25
|
+
* Including an absent field, or reordering them, changes the separator and
|
|
26
|
+
* therefore binds the signature to a domain nobody will verify against.
|
|
27
|
+
*/
|
|
28
|
+
export declare function domainSeparator(domain: Domain): Uint8Array;
|
|
29
|
+
export type TypedDataResult = {
|
|
30
|
+
readonly digest: string;
|
|
31
|
+
readonly domainSeparator: string;
|
|
32
|
+
readonly messageHash: string;
|
|
33
|
+
readonly typeHash: string;
|
|
34
|
+
readonly encodedType: string;
|
|
35
|
+
readonly primaryType: string;
|
|
36
|
+
};
|
|
37
|
+
/** The EIP-712 digest: keccak256(0x1901 then domainSeparator then hashStruct). */
|
|
38
|
+
export declare function typedDataDigest(domain: Domain, types: TypedTypes, primaryType: string, message: Record<string, unknown>): TypedDataResult;
|
|
39
|
+
/**
|
|
40
|
+
* EIP-191 `personal_sign`.
|
|
41
|
+
*
|
|
42
|
+
* The prefix is what stops a signed message from being replayable as a
|
|
43
|
+
* transaction. The length is the BYTE length, not the character count, so
|
|
44
|
+
* any non-ASCII character hashes differently if that is got wrong.
|
|
45
|
+
*/
|
|
46
|
+
export declare function personalSignHash(message: string): string;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIP-712 typed-data hashing and EIP-191 message hashing.
|
|
3
|
+
*
|
|
4
|
+
* This computes the digest a wallet signs. It does NOT sign: no private key
|
|
5
|
+
* is accepted, read or handled anywhere in this package. Producing the
|
|
6
|
+
* digest is arithmetic over a schema and can be checked; holding a key is a
|
|
7
|
+
* different job with different consequences.
|
|
8
|
+
*
|
|
9
|
+
* The value of computing it separately is that a signature request can be
|
|
10
|
+
* checked BEFORE it is approved — a digest computed from the message a human
|
|
11
|
+
* was actually shown, compared against the one the dapp asked for.
|
|
12
|
+
*/
|
|
13
|
+
import { keccak256, toHex } from "@crewhaus/tool-encode";
|
|
14
|
+
import { encodeTuple, parseType } from "./abi";
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const hash = (bytes) => keccak256(bytes);
|
|
17
|
+
const concat = (parts) => {
|
|
18
|
+
const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0));
|
|
19
|
+
let at = 0;
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
out.set(p, at);
|
|
22
|
+
at += p.length;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
/** The struct types `primary` refers to, transitively. */
|
|
27
|
+
function referencedTypes(primary, types, seen = new Set()) {
|
|
28
|
+
if (seen.has(primary))
|
|
29
|
+
return seen;
|
|
30
|
+
const fields = types[primary];
|
|
31
|
+
if (!fields)
|
|
32
|
+
return seen;
|
|
33
|
+
seen.add(primary);
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
const base = field.type.replace(/(\[\d*\])+$/, "");
|
|
36
|
+
if (types[base])
|
|
37
|
+
referencedTypes(base, types, seen);
|
|
38
|
+
}
|
|
39
|
+
return seen;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `encodeType`: the primary struct first, then every referenced struct in
|
|
43
|
+
* alphabetical order. The order is part of the standard — a different order
|
|
44
|
+
* gives a different type hash and therefore a signature no verifier accepts.
|
|
45
|
+
*/
|
|
46
|
+
export function encodeType(primary, types) {
|
|
47
|
+
const referenced = [...referencedTypes(primary, types)].filter((t) => t !== primary).sort();
|
|
48
|
+
return [primary, ...referenced]
|
|
49
|
+
.map((name) => {
|
|
50
|
+
const fields = types[name];
|
|
51
|
+
if (!fields)
|
|
52
|
+
throw new Error(`the type "${name}" is referenced but not defined`);
|
|
53
|
+
return `${name}(${fields.map((f) => `${f.type} ${f.name}`).join(",")})`;
|
|
54
|
+
})
|
|
55
|
+
.join("");
|
|
56
|
+
}
|
|
57
|
+
export function typeHash(primary, types) {
|
|
58
|
+
return hash(encoder.encode(encodeType(primary, types)));
|
|
59
|
+
}
|
|
60
|
+
/** One field, as the 32 bytes it contributes to a struct's encoding. */
|
|
61
|
+
function encodeField(type, value, types, what) {
|
|
62
|
+
const arrayMatch = /^(.*)\[(\d*)\]$/.exec(type);
|
|
63
|
+
if (arrayMatch) {
|
|
64
|
+
if (!Array.isArray(value))
|
|
65
|
+
throw new Error(`${what}: expected an array`);
|
|
66
|
+
const inner = arrayMatch[1];
|
|
67
|
+
const expected = arrayMatch[2];
|
|
68
|
+
if (expected !== undefined &&
|
|
69
|
+
expected !== "" &&
|
|
70
|
+
value.length !== Number.parseInt(expected, 10)) {
|
|
71
|
+
throw new Error(`${what}: expected ${expected} items, got ${value.length}`);
|
|
72
|
+
}
|
|
73
|
+
// An array contributes the hash of its members' encodings.
|
|
74
|
+
return hash(concat(value.map((item, i) => encodeField(inner, item, types, `${what}[${i}]`))));
|
|
75
|
+
}
|
|
76
|
+
if (types[type])
|
|
77
|
+
return hashStruct(type, value, types);
|
|
78
|
+
if (type === "string")
|
|
79
|
+
return hash(encoder.encode(String(value)));
|
|
80
|
+
if (type === "bytes") {
|
|
81
|
+
const text = String(value).replace(/^0x/i, "");
|
|
82
|
+
if (!/^[0-9a-fA-F]*$/.test(text))
|
|
83
|
+
throw new Error(`${what}: bytes must be hex`);
|
|
84
|
+
const bytes = new Uint8Array(text.length / 2);
|
|
85
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
86
|
+
bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
87
|
+
}
|
|
88
|
+
return hash(bytes);
|
|
89
|
+
}
|
|
90
|
+
// Everything else must be an atomic ABI type. When it is not, the likely
|
|
91
|
+
// mistake is a struct named in a field and left out of `types`, so say
|
|
92
|
+
// both possibilities rather than only "not an ABI type".
|
|
93
|
+
try {
|
|
94
|
+
return encodeTuple([parseType(type)], [value], what);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
throw new Error(`${what}: "${type}" is neither a struct defined in types nor an ABI type (${err.message})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function hashStruct(primary, data, types) {
|
|
101
|
+
const fields = types[primary];
|
|
102
|
+
if (!fields)
|
|
103
|
+
throw new Error(`the type "${primary}" is not defined`);
|
|
104
|
+
const parts = [typeHash(primary, types)];
|
|
105
|
+
for (const field of fields) {
|
|
106
|
+
if (!(field.name in data)) {
|
|
107
|
+
throw new Error(`"${primary}" requires the field "${field.name}", which the message does not have`);
|
|
108
|
+
}
|
|
109
|
+
parts.push(encodeField(field.type, data[field.name], types, `${primary}.${field.name}`));
|
|
110
|
+
}
|
|
111
|
+
return hash(concat(parts));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The domain separator.
|
|
115
|
+
*
|
|
116
|
+
* Only the fields actually present are included, in the standard's order.
|
|
117
|
+
* Including an absent field, or reordering them, changes the separator and
|
|
118
|
+
* therefore binds the signature to a domain nobody will verify against.
|
|
119
|
+
*/
|
|
120
|
+
export function domainSeparator(domain) {
|
|
121
|
+
const order = [
|
|
122
|
+
["name", "string"],
|
|
123
|
+
["version", "string"],
|
|
124
|
+
["chainId", "uint256"],
|
|
125
|
+
["verifyingContract", "address"],
|
|
126
|
+
["salt", "bytes32"],
|
|
127
|
+
];
|
|
128
|
+
const present = order.filter(([key]) => domain[key] !== undefined);
|
|
129
|
+
const types = {
|
|
130
|
+
EIP712Domain: present.map(([name, type]) => ({ name: name, type })),
|
|
131
|
+
};
|
|
132
|
+
return hashStruct("EIP712Domain", domain, types);
|
|
133
|
+
}
|
|
134
|
+
/** The EIP-712 digest: keccak256(0x1901 then domainSeparator then hashStruct). */
|
|
135
|
+
export function typedDataDigest(domain, types, primaryType, message) {
|
|
136
|
+
// A caller may pass the full JSON payload, EIP712Domain included; it is
|
|
137
|
+
// defined by the standard and must not take part in encodeType.
|
|
138
|
+
const withoutDomain = Object.fromEntries(Object.entries(types).filter(([name]) => name !== "EIP712Domain"));
|
|
139
|
+
const separator = domainSeparator(domain);
|
|
140
|
+
const structHash = hashStruct(primaryType, message, withoutDomain);
|
|
141
|
+
const digest = hash(concat([new Uint8Array([0x19, 0x01]), separator, structHash]));
|
|
142
|
+
return {
|
|
143
|
+
digest: `0x${toHex(digest)}`,
|
|
144
|
+
domainSeparator: `0x${toHex(separator)}`,
|
|
145
|
+
messageHash: `0x${toHex(structHash)}`,
|
|
146
|
+
typeHash: `0x${toHex(typeHash(primaryType, withoutDomain))}`,
|
|
147
|
+
encodedType: encodeType(primaryType, withoutDomain),
|
|
148
|
+
primaryType,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The 0x19 byte that begins every EIP-191 signed payload.
|
|
153
|
+
*
|
|
154
|
+
* Built from its code point rather than written as an escape: a formatter
|
|
155
|
+
* that rewrites the escape into the raw byte would put a control character
|
|
156
|
+
* into this file, and a file carrying one is not parsed identically by every
|
|
157
|
+
* engine. The same rewrite once broke this repository's CI.
|
|
158
|
+
*/
|
|
159
|
+
const EIP191_PREFIX = String.fromCharCode(0x19);
|
|
160
|
+
/**
|
|
161
|
+
* EIP-191 `personal_sign`.
|
|
162
|
+
*
|
|
163
|
+
* The prefix is what stops a signed message from being replayable as a
|
|
164
|
+
* transaction. The length is the BYTE length, not the character count, so
|
|
165
|
+
* any non-ASCII character hashes differently if that is got wrong.
|
|
166
|
+
*/
|
|
167
|
+
export function personalSignHash(message) {
|
|
168
|
+
const bytes = encoder.encode(message);
|
|
169
|
+
const prefix = encoder.encode(`${EIP191_PREFIX}Ethereum Signed Message:\n${bytes.length}`);
|
|
170
|
+
return `0x${toHex(hash(concat([prefix, bytes])))}`;
|
|
171
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-onchain",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic onchain tools: ABI encoding and decoding, EIP-55 address checksums, EIP-712 and EIP-191 hashing, unit conversion and fixed-point DeFi maths",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/errors": "0.7.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
21
|
+
"@crewhaus/tool-encode": "0.7.0",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Max Meier",
|
|
30
|
+
"email": "max@crewhaus.ai",
|
|
31
|
+
"url": "https://crewhaus.ai"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
36
|
+
"directory": "packages/tool-onchain"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-onchain#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
46
|
+
}
|