@ethlimo/ens-hooks-release-testing 0.1.11
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/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/contracts/AllParameterPermutationsHookTarget.sol +155 -0
- package/contracts/DataResolver.sol +17 -0
- package/contracts/FunctionCallParserTest.sol +203 -0
- package/contracts/IDataResolver.sol +9 -0
- package/contracts/ZeroParameterHookTarget.sol +19 -0
- package/package.json +69 -0
- package/sbom.spdx.json +1 -0
- package/src/dataurl/constants.ts +19 -0
- package/src/dataurl/encoding.ts +587 -0
- package/src/dataurl/index.ts +164 -0
- package/src/dataurl/trust.ts +39 -0
- package/src/index.ts +49 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ethers } from "ethers";
|
|
2
|
+
|
|
3
|
+
// EIP-8121: Hook function signature (without optional selector)
|
|
4
|
+
export const HookAbi = [
|
|
5
|
+
"function hook(string calldata functionSignature, string calldata functionCall, string calldata returnType, bytes calldata target)"
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
// EIP-8121: Hook selector (no optional function selector variant)
|
|
9
|
+
export const HOOK_SELECTOR = "0x6113bfa3";
|
|
10
|
+
|
|
11
|
+
// ENSIP-24
|
|
12
|
+
export const IDataResolverAbi = [
|
|
13
|
+
"function data(bytes32 node) external view returns (bytes memory)",
|
|
14
|
+
"event DataChanged(bytes32 indexed node, bytes32 indexed dataHash)"
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Contenthash protocol codes
|
|
18
|
+
export const PROTOCODE_ETH_CALLDATA = ethers.getBytes("0x30009b");
|
|
19
|
+
export const PROTOCODE_CONTENTHASH_URI = ethers.getBytes("0x3000f2");
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { BytesLike, ethers, getBytes, toUtf8Bytes, toUtf8String } from "ethers";
|
|
2
|
+
import { HookAbi, PROTOCODE_ETH_CALLDATA, PROTOCODE_CONTENTHASH_URI, HOOK_SELECTOR } from "./constants.js";
|
|
3
|
+
import { buildFromPayload, getAddress, getChainId } from "@wonderland/interop-addresses";
|
|
4
|
+
|
|
5
|
+
export const HookInterface = new ethers.Interface(HookAbi);
|
|
6
|
+
|
|
7
|
+
export function encodeDataUri(uri: string): string {
|
|
8
|
+
const uriBytes = toUtf8Bytes(uri);
|
|
9
|
+
return ethers.concat([PROTOCODE_CONTENTHASH_URI, uriBytes]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function tryDecodeDataUri(data: BytesLike): string | null {
|
|
13
|
+
try {
|
|
14
|
+
const bytes = getBytes(data);
|
|
15
|
+
const protocode = PROTOCODE_CONTENTHASH_URI;
|
|
16
|
+
|
|
17
|
+
if (bytes.length < protocode.length) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < protocode.length; i++) {
|
|
22
|
+
if (bytes[i] !== protocode[i]) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const uriBytes = bytes.slice(protocode.length);
|
|
28
|
+
return toUtf8String(uriBytes);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function encodeEIP8121HookForContenthash(hookData: string): string {
|
|
35
|
+
return ethers.concat([PROTOCODE_ETH_CALLDATA, hookData]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function tryDecodeEIP8121HookFromContenthash(data: BytesLike): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const bytes = getBytes(data);
|
|
41
|
+
const protocode = PROTOCODE_ETH_CALLDATA;
|
|
42
|
+
|
|
43
|
+
if (bytes.length < protocode.length) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < protocode.length; i++) {
|
|
48
|
+
if (bytes[i] !== protocode[i]) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return ethers.hexlify(bytes.slice(protocode.length));
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EIP8121Target {
|
|
60
|
+
chainId: number;
|
|
61
|
+
address: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface HookParameter {
|
|
65
|
+
type: string;
|
|
66
|
+
value: any;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DecodedEIP8121Hook {
|
|
70
|
+
functionSignature: string;
|
|
71
|
+
functionCall: string;
|
|
72
|
+
returnType: string;
|
|
73
|
+
target: EIP8121Target;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function encodeERC7930Target(chainId: number, address: string): Promise<string> {
|
|
77
|
+
const binaryAddress = await buildFromPayload({
|
|
78
|
+
version: 1,
|
|
79
|
+
chainType: "eip155",
|
|
80
|
+
chainReference: chainId.toString(),
|
|
81
|
+
address: address,
|
|
82
|
+
});
|
|
83
|
+
return binaryAddress;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function decodeERC7930Target(target: string): Promise<EIP8121Target | null> {
|
|
87
|
+
try {
|
|
88
|
+
const chainId = await getChainId(target);
|
|
89
|
+
const address = await getAddress(target);
|
|
90
|
+
|
|
91
|
+
if (typeof chainId !== 'number') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { chainId, address };
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function computeSelector(functionSignature: string): string {
|
|
102
|
+
const hash = ethers.id(functionSignature);
|
|
103
|
+
return ethers.dataSlice(hash, 0, 4);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validates that a parameter type is allowed in this implementation.
|
|
108
|
+
* Allowed types: bool, address, uint8-256, int8-256, bytes1-32, string (max 512 chars)
|
|
109
|
+
* Rejects: bytes (dynamic), arrays, structs, tuples
|
|
110
|
+
*/
|
|
111
|
+
export function isAllowedParameterType(type: string): boolean {
|
|
112
|
+
const trimmedType = type.trim();
|
|
113
|
+
|
|
114
|
+
// Check for exact matches
|
|
115
|
+
if (trimmedType === 'bool' || trimmedType === 'address' || trimmedType === 'string') {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for uintN (uint8, uint16, ..., uint256)
|
|
120
|
+
const uintMatch = trimmedType.match(/^uint(\d+)$/);
|
|
121
|
+
if (uintMatch) {
|
|
122
|
+
const bits = parseInt(uintMatch[1], 10);
|
|
123
|
+
return bits >= 8 && bits <= 256 && bits % 8 === 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check for intN (int8, int16, ..., int256)
|
|
127
|
+
const intMatch = trimmedType.match(/^int(\d+)$/);
|
|
128
|
+
if (intMatch) {
|
|
129
|
+
const bits = parseInt(intMatch[1], 10);
|
|
130
|
+
return bits >= 8 && bits <= 256 && bits % 8 === 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for bytesN (bytes1, bytes2, ..., bytes32)
|
|
134
|
+
const bytesMatch = trimmedType.match(/^bytes(\d+)$/);
|
|
135
|
+
if (bytesMatch) {
|
|
136
|
+
const n = parseInt(bytesMatch[1], 10);
|
|
137
|
+
return n >= 1 && n <= 32;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Parses parameter types from a function signature.
|
|
145
|
+
* Returns array of parameter types, or throws if signature is invalid.
|
|
146
|
+
*/
|
|
147
|
+
export function parseParameterTypes(functionSignature: string): string[] {
|
|
148
|
+
const match = functionSignature.match(/^\w+\(([^)]*)\)$/);
|
|
149
|
+
if (!match) {
|
|
150
|
+
throw new Error('Invalid function signature format');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const paramsString = match[1].trim();
|
|
154
|
+
if (!paramsString) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Split by comma
|
|
159
|
+
const params = paramsString.split(',').map(p => p.trim()).filter(p => p.length > 0);
|
|
160
|
+
|
|
161
|
+
// Validate parameter count (0-2 only)
|
|
162
|
+
if (params.length > 2) {
|
|
163
|
+
throw new Error(`Too many parameters: ${params.length}. Maximum 2 parameters allowed.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Validate each parameter type
|
|
167
|
+
for (const param of params) {
|
|
168
|
+
if (!isAllowedParameterType(param)) {
|
|
169
|
+
throw new Error(`Unsupported parameter type: ${param}. Only fixed-size primitives and strings (max 512 chars) allowed (bool, address, uintN, intN, bytesN, string).`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return params;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extracts function name from function signature or function call.
|
|
178
|
+
*/
|
|
179
|
+
export function extractFunctionName(functionString: string): string {
|
|
180
|
+
const match = functionString.match(/^(\w+)\(/);
|
|
181
|
+
if (!match) {
|
|
182
|
+
throw new Error('Invalid function format');
|
|
183
|
+
}
|
|
184
|
+
return match[1];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Maximum allowed length for string parameters (512 characters)
|
|
189
|
+
*/
|
|
190
|
+
export const MAX_STRING_LENGTH = 512;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validates that an integer value is within the valid range for its type.
|
|
194
|
+
* Uses BigInt for arbitrary precision to handle uint256/int256 correctly.
|
|
195
|
+
*/
|
|
196
|
+
function validateIntegerRange(value: string, paramType: string, paramIndex: number, detailedErrors: boolean): void {
|
|
197
|
+
const isUnsigned = paramType.startsWith('uint');
|
|
198
|
+
const bitsStr = paramType.replace(/^u?int/, '');
|
|
199
|
+
const bits = bitsStr ? parseInt(bitsStr, 10) : 256;
|
|
200
|
+
|
|
201
|
+
// Reject excessively long numeric strings before BigInt parsing to prevent DoS
|
|
202
|
+
// Max for uint256: 78 decimal digits or 64 hex digits (+ 2 for '0x')
|
|
203
|
+
// Max for int256: 78 digits + 1 for sign = 79 decimal, or 64 hex + 2 = 66 with '0x'
|
|
204
|
+
const isHex = value.startsWith('0x') || value.startsWith('0X');
|
|
205
|
+
const maxLength = isHex ? 66 : 79; // Max length including prefix/sign
|
|
206
|
+
|
|
207
|
+
if (value.length > maxLength) {
|
|
208
|
+
throw new Error(detailedErrors
|
|
209
|
+
? `Parameter ${paramIndex + 1} (type ${paramType}): numeric string too long (max ${maxLength} characters)`
|
|
210
|
+
: 'Failed to parse function call parameters');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Parse value as BigInt (handles both decimal and hex)
|
|
214
|
+
let numValue: bigint;
|
|
215
|
+
try {
|
|
216
|
+
numValue = BigInt(value);
|
|
217
|
+
} catch {
|
|
218
|
+
throw new Error(detailedErrors
|
|
219
|
+
? `Parameter ${paramIndex + 1} (type ${paramType}): invalid number format`
|
|
220
|
+
: 'Failed to parse function call parameters');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isUnsigned) {
|
|
224
|
+
// uint: 0 to 2^bits - 1
|
|
225
|
+
const maxValue = (BigInt(1) << BigInt(bits)) - BigInt(1);
|
|
226
|
+
if (numValue < BigInt(0) || numValue > maxValue) {
|
|
227
|
+
throw new Error(detailedErrors
|
|
228
|
+
? `Parameter ${paramIndex + 1} (type ${paramType}): value ${value} out of range (0 to ${maxValue})`
|
|
229
|
+
: 'Failed to parse function call parameters');
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// int: -2^(bits-1) to 2^(bits-1) - 1
|
|
233
|
+
const minValue = -(BigInt(1) << BigInt(bits - 1));
|
|
234
|
+
const maxValue = (BigInt(1) << BigInt(bits - 1)) - BigInt(1);
|
|
235
|
+
if (numValue < minValue || numValue > maxValue) {
|
|
236
|
+
throw new Error(detailedErrors
|
|
237
|
+
? `Parameter ${paramIndex + 1} (type ${paramType}): value ${value} out of range (${minValue} to ${maxValue})`
|
|
238
|
+
: 'Failed to parse function call parameters');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parses a single quoted string value, handling escape sequences \' and \\
|
|
245
|
+
* Returns the unescaped string value and the position after the closing quote.
|
|
246
|
+
*/
|
|
247
|
+
function parseQuotedString(input: string, startPos: number): { value: string; endPos: number } {
|
|
248
|
+
let result = '';
|
|
249
|
+
let pos = startPos;
|
|
250
|
+
let escaped = false;
|
|
251
|
+
|
|
252
|
+
while (pos < input.length) {
|
|
253
|
+
const char = input[pos];
|
|
254
|
+
|
|
255
|
+
if (escaped) {
|
|
256
|
+
// Only support \' and \\ escape sequences
|
|
257
|
+
if (char === "'" || char === '\\') {
|
|
258
|
+
result += char;
|
|
259
|
+
} else {
|
|
260
|
+
throw new Error(`Invalid escape sequence: \\${char}. Only \\' and \\\\ are supported.`);
|
|
261
|
+
}
|
|
262
|
+
escaped = false;
|
|
263
|
+
pos++;
|
|
264
|
+
} else if (char === '\\') {
|
|
265
|
+
escaped = true;
|
|
266
|
+
pos++;
|
|
267
|
+
} else if (char === "'") {
|
|
268
|
+
// End of string
|
|
269
|
+
return { value: result, endPos: pos + 1 };
|
|
270
|
+
} else {
|
|
271
|
+
result += char;
|
|
272
|
+
pos++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
throw new Error('Unterminated string: missing closing quote');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parses parameter values from a functionCall string according to functionSignature types.
|
|
281
|
+
* Supports: hex (0x...), booleans (true/false), quoted strings with \' and \\ escapes, decimal numbers.
|
|
282
|
+
* Validates string length limits (512 chars) and proper formatting.
|
|
283
|
+
* Returns array of parsed values ready for ABI encoding.
|
|
284
|
+
*/
|
|
285
|
+
export function parseFunctionCallValues(
|
|
286
|
+
functionCall: string,
|
|
287
|
+
functionSignature: string,
|
|
288
|
+
detailedErrors: boolean = true
|
|
289
|
+
): any[] {
|
|
290
|
+
try {
|
|
291
|
+
// Extract function name and validate it matches
|
|
292
|
+
const callName = extractFunctionName(functionCall);
|
|
293
|
+
const sigName = extractFunctionName(functionSignature);
|
|
294
|
+
|
|
295
|
+
if (callName !== sigName) {
|
|
296
|
+
throw new Error(`Function name mismatch: signature has '${sigName}' but call has '${callName}'`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Get parameter types from signature
|
|
300
|
+
const paramTypes = parseParameterTypes(functionSignature);
|
|
301
|
+
|
|
302
|
+
// Extract the parameters string from functionCall by locating outer parentheses
|
|
303
|
+
const trimmedCall = functionCall.trim();
|
|
304
|
+
const openParenIndex = trimmedCall.indexOf('(');
|
|
305
|
+
const closeParenIndex = trimmedCall.lastIndexOf(')');
|
|
306
|
+
if (openParenIndex === -1 || closeParenIndex === -1 || closeParenIndex < openParenIndex) {
|
|
307
|
+
throw new Error('Invalid function call format');
|
|
308
|
+
}
|
|
309
|
+
// Ensure there is no trailing non-parenthesis content after the closing ')'
|
|
310
|
+
if (closeParenIndex !== trimmedCall.length - 1) {
|
|
311
|
+
throw new Error('Invalid function call format');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const paramsString = trimmedCall.slice(openParenIndex + 1, closeParenIndex).trim();
|
|
315
|
+
|
|
316
|
+
// Handle zero parameters
|
|
317
|
+
if (paramTypes.length === 0) {
|
|
318
|
+
if (paramsString.length > 0) {
|
|
319
|
+
throw new Error('Function expects 0 parameters but call has parameters');
|
|
320
|
+
}
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Parse parameter values
|
|
325
|
+
const values: any[] = [];
|
|
326
|
+
let pos = 0;
|
|
327
|
+
let paramIndex = 0;
|
|
328
|
+
|
|
329
|
+
while (pos < paramsString.length && paramIndex < paramTypes.length) {
|
|
330
|
+
// Skip whitespace
|
|
331
|
+
while (pos < paramsString.length && /\s/.test(paramsString[pos])) {
|
|
332
|
+
pos++;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (pos >= paramsString.length) break;
|
|
336
|
+
|
|
337
|
+
const paramType = paramTypes[paramIndex].trim();
|
|
338
|
+
|
|
339
|
+
// Parse based on type
|
|
340
|
+
if (paramType === 'string') {
|
|
341
|
+
// Expect a quoted string
|
|
342
|
+
if (paramsString[pos] !== "'") {
|
|
343
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): expected quoted string starting with '`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const { value, endPos } = parseQuotedString(paramsString, pos + 1);
|
|
347
|
+
|
|
348
|
+
// Validate string length
|
|
349
|
+
if (value.length > MAX_STRING_LENGTH) {
|
|
350
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): string too long (${value.length} chars, max ${MAX_STRING_LENGTH})`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
values.push(value);
|
|
354
|
+
pos = endPos;
|
|
355
|
+
} else if (paramType === 'bool') {
|
|
356
|
+
// Expect true or false
|
|
357
|
+
if (paramsString.startsWith('true', pos)) {
|
|
358
|
+
values.push(true);
|
|
359
|
+
pos += 4;
|
|
360
|
+
} else if (paramsString.startsWith('false', pos)) {
|
|
361
|
+
values.push(false);
|
|
362
|
+
pos += 5;
|
|
363
|
+
} else {
|
|
364
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): expected 'true' or 'false'`);
|
|
365
|
+
}
|
|
366
|
+
} else if (paramType === 'address' || paramType.startsWith('bytes') || paramType.startsWith('uint') || paramType.startsWith('int')) {
|
|
367
|
+
// Parse until comma or end
|
|
368
|
+
let valueEnd = pos;
|
|
369
|
+
let parenDepth = 0;
|
|
370
|
+
|
|
371
|
+
while (valueEnd < paramsString.length) {
|
|
372
|
+
const char = paramsString[valueEnd];
|
|
373
|
+
if (char === '(' ) parenDepth++;
|
|
374
|
+
if (char === ')' ) parenDepth--;
|
|
375
|
+
if (char === ',' && parenDepth === 0) break;
|
|
376
|
+
valueEnd++;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const valueStr = paramsString.substring(pos, valueEnd).trim();
|
|
380
|
+
|
|
381
|
+
if (paramType === 'address') {
|
|
382
|
+
// Validate hex address format
|
|
383
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(valueStr)) {
|
|
384
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): invalid address format, expected 0x followed by 40 hex characters`);
|
|
385
|
+
}
|
|
386
|
+
values.push(valueStr);
|
|
387
|
+
} else if (paramType.startsWith('bytes')) {
|
|
388
|
+
// Validate hex bytes format
|
|
389
|
+
const bytesMatch = paramType.match(/^bytes(\d+)$/);
|
|
390
|
+
if (bytesMatch) {
|
|
391
|
+
const expectedBytes = parseInt(bytesMatch[1], 10);
|
|
392
|
+
const expectedHexLength = expectedBytes * 2;
|
|
393
|
+
|
|
394
|
+
if (!/^0x[0-9a-fA-F]+$/.test(valueStr)) {
|
|
395
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): invalid hex format, expected 0x followed by hex characters`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const hexPart = valueStr.substring(2);
|
|
399
|
+
if (hexPart.length !== expectedHexLength) {
|
|
400
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): expected ${expectedHexLength} hex characters (${expectedBytes} bytes), got ${hexPart.length}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
values.push(valueStr);
|
|
404
|
+
}
|
|
405
|
+
} else if (paramType.startsWith('uint') || paramType.startsWith('int')) {
|
|
406
|
+
// Parse as hex or decimal number
|
|
407
|
+
if (valueStr.startsWith('0x') || valueStr.startsWith('0X')) {
|
|
408
|
+
// Hex number (normalize to lowercase for validation)
|
|
409
|
+
if (!/^0[xX][0-9a-fA-F]+$/.test(valueStr)) {
|
|
410
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): invalid hex number format`);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
// Decimal number
|
|
414
|
+
if (!/^-?\d+$/.test(valueStr)) {
|
|
415
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): invalid decimal number format`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Check if negative number is valid for the type
|
|
419
|
+
if (paramType.startsWith('uint') && valueStr.startsWith('-')) {
|
|
420
|
+
throw new Error(`Parameter ${paramIndex + 1} (type ${paramType}): unsigned integer cannot be negative`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Validate integer is within range for its type
|
|
425
|
+
validateIntegerRange(valueStr, paramType, paramIndex, detailedErrors);
|
|
426
|
+
values.push(valueStr);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
pos = valueEnd;
|
|
430
|
+
} else {
|
|
431
|
+
throw new Error(`Unsupported parameter type: ${paramType}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Skip whitespace after value
|
|
435
|
+
while (pos < paramsString.length && /\s/.test(paramsString[pos])) {
|
|
436
|
+
pos++;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Check for comma separator (unless this is the last parameter)
|
|
440
|
+
if (paramIndex < paramTypes.length - 1) {
|
|
441
|
+
if (pos >= paramsString.length || paramsString[pos] !== ',') {
|
|
442
|
+
throw new Error(`Expected comma after parameter ${paramIndex + 1}`);
|
|
443
|
+
}
|
|
444
|
+
pos++; // Skip comma
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
paramIndex++;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Verify we parsed all parameters
|
|
451
|
+
if (paramIndex !== paramTypes.length) {
|
|
452
|
+
throw new Error(`Parameter count mismatch: signature has ${paramTypes.length} parameters but call has ${paramIndex}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Verify no trailing content
|
|
456
|
+
while (pos < paramsString.length) {
|
|
457
|
+
if (!/\s/.test(paramsString[pos])) {
|
|
458
|
+
throw new Error('Unexpected content after parameters');
|
|
459
|
+
}
|
|
460
|
+
pos++;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return values;
|
|
464
|
+
} catch (error: any) {
|
|
465
|
+
if (detailedErrors) {
|
|
466
|
+
throw error;
|
|
467
|
+
} else {
|
|
468
|
+
// Generic error message for security
|
|
469
|
+
throw new Error('Failed to parse function call parameters');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Strictly validates that functionCall matches functionSignature structure.
|
|
476
|
+
* Validates function name matches and parameter count matches.
|
|
477
|
+
*/
|
|
478
|
+
export function validateFunctionCallMatchesSignature(
|
|
479
|
+
functionSignature: string,
|
|
480
|
+
functionCall: string
|
|
481
|
+
): void {
|
|
482
|
+
const sigName = extractFunctionName(functionSignature);
|
|
483
|
+
const callName = extractFunctionName(functionCall);
|
|
484
|
+
|
|
485
|
+
if (sigName !== callName) {
|
|
486
|
+
throw new Error(`Function name mismatch: signature has '${sigName}' but call has '${callName}'`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const paramTypes = parseParameterTypes(functionSignature);
|
|
490
|
+
|
|
491
|
+
// Parse parameter values from function call
|
|
492
|
+
const callMatch = functionCall.match(/^\w+\(([^)]*)\)$/);
|
|
493
|
+
if (!callMatch) {
|
|
494
|
+
throw new Error('Invalid function call format');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const callParamsString = callMatch[1].trim();
|
|
498
|
+
|
|
499
|
+
// Count parameters in call (simple comma split - ethers will validate actual values)
|
|
500
|
+
let callParamCount = 0;
|
|
501
|
+
if (callParamsString) {
|
|
502
|
+
// Simple approach: count commas + 1, accounting for empty string
|
|
503
|
+
callParamCount = callParamsString.split(',').filter(p => p.trim().length > 0).length;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (callParamCount !== paramTypes.length) {
|
|
507
|
+
throw new Error(`Parameter count mismatch: signature has ${paramTypes.length} parameters but call has ${callParamCount}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Encodes a hook with the ERC-8121 format (functionSignature, functionCall, returnType, target).
|
|
513
|
+
* Validates that functionCall matches functionSignature and parameter values are properly formatted.
|
|
514
|
+
* Validates string parameters are within 512 character limit.
|
|
515
|
+
*/
|
|
516
|
+
export async function encodeHook(
|
|
517
|
+
functionSignature: string,
|
|
518
|
+
functionCall: string,
|
|
519
|
+
returnType: string,
|
|
520
|
+
target: EIP8121Target
|
|
521
|
+
): Promise<string> {
|
|
522
|
+
// Validate parameter types (0-2 parameters, including strings)
|
|
523
|
+
parseParameterTypes(functionSignature);
|
|
524
|
+
|
|
525
|
+
// Parse and validate functionCall values with detailed error messages
|
|
526
|
+
// This ensures parameter values are properly formatted and within limits
|
|
527
|
+
parseFunctionCallValues(functionCall, functionSignature, true);
|
|
528
|
+
|
|
529
|
+
// Validate return type is bytes
|
|
530
|
+
if (returnType.trim() !== '(bytes)') {
|
|
531
|
+
throw new Error(`Invalid return type: ${returnType}. Only (bytes) return type is supported.`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const targetBytes = await encodeERC7930Target(target.chainId, target.address);
|
|
535
|
+
return HookInterface.encodeFunctionData("hook", [
|
|
536
|
+
functionSignature,
|
|
537
|
+
functionCall,
|
|
538
|
+
returnType,
|
|
539
|
+
targetBytes
|
|
540
|
+
]);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function decodeHook(data: string): Promise<DecodedEIP8121Hook | null> {
|
|
544
|
+
try {
|
|
545
|
+
const decoded = HookInterface.decodeFunctionData("hook", data);
|
|
546
|
+
const target = await decodeERC7930Target(decoded[3]);
|
|
547
|
+
|
|
548
|
+
if (!target) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
functionSignature: decoded[0],
|
|
554
|
+
functionCall: decoded[1],
|
|
555
|
+
returnType: decoded[2],
|
|
556
|
+
target
|
|
557
|
+
};
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function isEIP8121Hook(data: string): boolean {
|
|
564
|
+
try {
|
|
565
|
+
const lower = data.toLowerCase();
|
|
566
|
+
if (lower.startsWith(HOOK_SELECTOR.toLowerCase())) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
const bytes = getBytes(data);
|
|
570
|
+
if (bytes.length >= PROTOCODE_ETH_CALLDATA.length + 4) {
|
|
571
|
+
let hasProtocodePrefix = true;
|
|
572
|
+
for (let i = 0; i < PROTOCODE_ETH_CALLDATA.length; i++) {
|
|
573
|
+
if (bytes[i] !== PROTOCODE_ETH_CALLDATA[i]) {
|
|
574
|
+
hasProtocodePrefix = false;
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (hasProtocodePrefix) {
|
|
579
|
+
const afterProtocol = ethers.hexlify(bytes.slice(PROTOCODE_ETH_CALLDATA.length));
|
|
580
|
+
return afterProtocol.toLowerCase().startsWith(HOOK_SELECTOR.toLowerCase());
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return false;
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|