src/components/contract-read.js 16.6 K raw
1
import { keccak_256 } from "@noble/hashes/sha3.js";
2
3
class ContractRead extends HTMLElement {
4
	/**
5
	 * ContractRead Web Component
6
	 *
7
	 * A custom HTML element for reading state from Ethereum smart contracts.
8
	 * Automatically loads and displays read-only contract state on mount.
9
	 * Supports both wallet integration and direct RPC calls.
10
	 *
11
	 * @example
12
	 * Basic usage with inline ABI and RPC URL:
13
	 * ```html
14
	 * <contract-read
15
	 *   contract-address="0x1234567890123456789012345678901234567890"
16
	 *   method-name="balanceOf"
17
	 *   method-args='["0xabcdef1234567890123456789012345678901234"]'
18
	 *   rpc-url="https://eth.llamarpc.com"
19
	 *   abi='[{"type":"function","name":"balanceOf","inputs":[{"type":"address","name":"owner"}],"outputs":[{"type":"uint256","name":""}],"stateMutability":"view"}]'>
20
	 * </contract-read>
21
	 * ```
22
	 *
23
	 * @example
24
	 * Using ABI from URL with wallet:
25
	 * ```html
26
	 * <contract-read
27
	 *   contract-address="0x1234567890123456789012345678901234567890"
28
	 *   method-name="totalSupply"
29
	 *   abi-url="https://api.example.com/contract-abi.json">
30
	 * </contract-read>
31
	 * ```
32
	 *
33
	 * @example
34
	 * Custom styling:
35
	 * ```html
36
	 * <contract-read
37
	 *   contract-address="0x1234567890123456789012345678901234567890"
38
	 *   method-name="getName"
39
	 *   abi-url="/abi/token.json"
40
	 *   rpc-url="https://eth.llamarpc.com"
41
	 *   background="#1a1a1a"
42
	 *   foreground="#ffffff"
43
	 *   primary="#00ff88"
44
	 *   border-radius="8px"
45
	 *   error-color="#ff4444"
46
	 *   success-color="#44ff44">
47
	 * </contract-read>
48
	 * ```
49
	 *
50
	 * Attributes:
51
	 * - contract-address (required): The Ethereum contract address
52
	 * - method-name (required): The contract method to call
53
	 * - method-args: JSON array of method arguments (default: [])
54
	 * - abi: JSON string of the contract ABI
55
	 * - abi-url: URL to fetch the contract ABI from
56
	 * - rpc-url: RPC URL for direct calls (used when wallet is not available)
57
	 * - chain-id: Ethereum chain ID in hex format (default: "0x1" for mainnet)
58
	 * - background: Background color (default: "#232323")
59
	 * - foreground: Text color (default: "#ffffff")
60
	 * - primary: Primary color (default: "#5F8787")
61
	 * - border-radius: Border radius for UI elements (default: "4px")
62
	 * - error-color: Color for error messages (default: "#E78A53")
63
	 * - success-color: Color for success messages (default: "#5F8787")
64
	 *
65
	 * Events:
66
	 * - abi-loaded: Fired when ABI is successfully loaded from URL
67
	 * - abi-error: Fired when ABI loading fails
68
	 * - contract-read-success: Fired when contract read succeeds
69
	 * - contract-read-error: Fired when contract read fails
70
	 *
71
	 * Requirements:
72
	 * - @noble/hashes library for keccak256 hashing
73
	 * - Either a wallet extension (MetaMask) or rpc-url attribute
74
	 *
75
	 * Notes:
76
	 * - Only supports read-only methods (view/pure)
77
	 * - Automatically loads state on component mount
78
	 * - Uses wallet if available, falls back to RPC URL
79
	 * - Simplified ABI encoding/decoding (use ethers.js/web3.js for production)
80
	 */
81
82
	// Constructor and lifecycle methods
83
	constructor() {
84
		super();
85
		this.attachShadow({ mode: "open" });
86
		this.loading = false;
87
		this.result = null;
88
		this.error = null;
89
		this.abi = null;
90
		this.methodData = null;
91
		this.contractAddress = "";
92
		this.chainId = "0x1";
93
		this.methodName = "";
94
		this.methodArgs = [];
95
		this.abiUrl = "";
96
		this.rpcUrl = "";
97
		this.isReadOnly = true;
98
	}
99
100
	static get observedAttributes() {
101
		return [
102
			"contract-address",
103
			"chain-id",
104
			"method-name",
105
			"method-args",
106
			"abi-url",
107
			"abi",
108
			"rpc-url",
109
			"background",
110
			"foreground",
111
			"primary",
112
			"border-radius",
113
			"error-color",
114
			"success-color",
115
		];
116
	}
117
118
	attributeChangedCallback(name, oldValue, newValue) {
119
		if (oldValue === newValue) return;
120
121
		switch (name) {
122
			case "contract-address":
123
				this.contractAddress = newValue || "";
124
				break;
125
			case "chain-id":
126
				this.chainId = this.normalizeChainId(newValue);
127
				break;
128
			case "method-name":
129
				this.methodName = newValue || "";
130
				this.parseMethodFromAbi();
131
				break;
132
			case "method-args":
133
				try {
134
					this.methodArgs = newValue ? JSON.parse(newValue) : [];
135
				} catch (e) {
136
					console.error("Invalid method-args JSON:", e);
137
					this.methodArgs = [];
138
				}
139
				break;
140
			case "abi-url":
141
				this.abiUrl = newValue || "";
142
				if (this.abiUrl) {
143
					this.fetchAbi();
144
				}
145
				break;
146
			case "abi":
147
				try {
148
					this.abi = newValue ? JSON.parse(newValue) : null;
149
					this.parseMethodFromAbi();
150
				} catch (e) {
151
					console.error("Invalid ABI JSON:", e);
152
					this.abi = null;
153
				}
154
				break;
155
			case "rpc-url":
156
				this.rpcUrl = newValue || "";
157
				break;
158
			default:
159
				if (
160
					[
161
						"background",
162
						"foreground",
163
						"primary",
164
						"border-radius",
165
						"error-color",
166
						"success-color",
167
					].includes(name)
168
				) {
169
					this.render();
170
				}
171
		}
172
	}
173
174
	connectedCallback() {
175
		this.contractAddress = this.getAttribute("contract-address") || "";
176
		const chainIdAttr = this.getAttribute("chain-id");
177
		this.chainId = this.normalizeChainId(chainIdAttr);
178
		this.methodName = this.getAttribute("method-name") || "";
179
		this.abiUrl = this.getAttribute("abi-url") || "";
180
		this.rpcUrl = this.getAttribute("rpc-url") || "";
181
182
		try {
183
			const methodArgsAttr = this.getAttribute("method-args");
184
			this.methodArgs = methodArgsAttr ? JSON.parse(methodArgsAttr) : [];
185
		} catch (e) {
186
			console.error("Invalid method-args JSON:", e);
187
			this.methodArgs = [];
188
		}
189
190
		try {
191
			const abiAttr = this.getAttribute("abi");
192
			this.abi = abiAttr ? JSON.parse(abiAttr) : null;
193
		} catch (e) {
194
			console.error("Invalid ABI JSON:", e);
195
			this.abi = null;
196
		}
197
198
		if (this.abiUrl && !this.abi) {
199
			this.fetchAbi().then(() => {
200
				// Auto-call contract after ABI is loaded
201
				if (this.methodData) {
202
					this.readContract();
203
				}
204
			});
205
		} else if (this.abi) {
206
			this.parseMethodFromAbi();
207
			// Auto-call contract on mount
208
			if (this.methodData) {
209
				this.readContract();
210
			}
211
		}
212
213
		this.render();
214
	}
215
216
	// ABI and method parsing
217
	async fetchAbi() {
218
		if (!this.abiUrl) return;
219
220
		try {
221
			this.loading = true;
222
			this.render();
223
224
			const response = await fetch(this.abiUrl);
225
			if (!response.ok) {
226
				throw new Error(`Failed to fetch ABI: ${response.statusText}`);
227
			}
228
229
			this.abi = await response.json();
230
			this.parseMethodFromAbi();
231
			this.loading = false;
232
			this.render();
233
234
			this.dispatchEvent(
235
				new CustomEvent("abi-loaded", {
236
					detail: { abi: this.abi },
237
				}),
238
			);
239
		} catch (error) {
240
			console.error("Failed to fetch ABI:", error);
241
			this.error = error.message;
242
			this.loading = false;
243
			this.render();
244
245
			this.dispatchEvent(
246
				new CustomEvent("abi-error", {
247
					detail: { error: error.message },
248
				}),
249
			);
250
		}
251
	}
252
253
	parseMethodFromAbi() {
254
		if (!this.abi || !this.methodName) return;
255
256
		const method = this.abi.find(
257
			(item) => item.type === "function" && item.name === this.methodName,
258
		);
259
260
		if (!method) {
261
			this.error = `Method '${this.methodName}' not found in ABI`;
262
			this.render();
263
			return;
264
		}
265
266
		this.methodData = method;
267
		this.isReadOnly =
268
			method.stateMutability === "view" || method.stateMutability === "pure";
269
		this.error = null;
270
		this.render();
271
	}
272
273
	/**
274
	 * Converts a numeric chain ID to hex format
275
	 * @param {string|number} chainId - Chain ID in numeric or hex format
276
	 * @returns {string} Chain ID in hex format (e.g., "0x2105")
277
	 */
278
	normalizeChainId(chainId) {
279
		if (!chainId) return "0x1";
280
281
		const chainIdStr = String(chainId);
282
283
		// If it's already in hex format (starts with 0x), return as-is
284
		if (chainIdStr.startsWith("0x")) {
285
			return chainIdStr.toLowerCase();
286
		}
287
288
		// Convert numeric string to hex
289
		const numericChainId = parseInt(chainIdStr, 10);
290
		if (isNaN(numericChainId)) {
291
			console.warn(`Invalid chain ID: ${chainId}, defaulting to 0x1`);
292
			return "0x1";
293
		}
294
295
		return `0x${numericChainId.toString(16)}`;
296
	}
297
298
	// Contract interaction methods
299
	async readContract() {
300
		if (!this.contractAddress || !this.methodName || !this.methodData) {
301
			this.error = "Missing required contract information";
302
			this.render();
303
			return;
304
		}
305
306
		try {
307
			this.loading = true;
308
			this.result = null;
309
			this.error = null;
310
			this.render();
311
312
			// Encode method call
313
			const methodSignature = this.encodeMethodSignature();
314
			const encodedArgs = this.encodeArguments();
315
			const data = methodSignature + encodedArgs;
316
317
			let result;
318
319
			// Try to use wallet first, fall back to RPC URL
320
			if (window.ethereum) {
321
				try {
322
					result = await window.ethereum.request({
323
						method: "eth_call",
324
						params: [
325
							{
326
								to: this.contractAddress,
327
								data: data,
328
							},
329
							"latest",
330
						],
331
					});
332
				} catch (walletError) {
333
					console.warn("Wallet call failed, trying RPC URL:", walletError);
334
					if (this.rpcUrl) {
335
						result = await this.callViaRpc(data);
336
					} else {
337
						throw walletError;
338
					}
339
				}
340
			} else if (this.rpcUrl) {
341
				// No wallet available, use RPC URL
342
				result = await this.callViaRpc(data);
343
			} else {
344
				throw new Error(
345
					"No wallet extension found and no RPC URL provided. Please install MetaMask or provide an rpc-url attribute.",
346
				);
347
			}
348
349
			this.result = this.decodeResult(result);
350
			this.loading = false;
351
			this.render();
352
353
			this.dispatchEvent(
354
				new CustomEvent("contract-read-success", {
355
					detail: {
356
						result: this.result,
357
						method: this.methodName,
358
						args: this.methodArgs,
359
					},
360
				}),
361
			);
362
		} catch (error) {
363
			console.error("Contract read failed:", error);
364
			this.error = error.message;
365
			this.loading = false;
366
			this.render();
367
368
			this.dispatchEvent(
369
				new CustomEvent("contract-read-error", {
370
					detail: { error: error.message },
371
				}),
372
			);
373
		}
374
	}
375
376
	async callViaRpc(data) {
377
		if (!this.rpcUrl) {
378
			throw new Error("RPC URL not configured");
379
		}
380
381
		const response = await fetch(this.rpcUrl, {
382
			method: "POST",
383
			headers: {
384
				"Content-Type": "application/json",
385
			},
386
			body: JSON.stringify({
387
				jsonrpc: "2.0",
388
				method: "eth_call",
389
				params: [
390
					{
391
						to: this.contractAddress,
392
						data: data,
393
					},
394
					"latest",
395
				],
396
				id: 1,
397
			}),
398
		});
399
400
		if (!response.ok) {
401
			throw new Error(`RPC request failed: ${response.statusText}`);
402
		}
403
404
		const json = await response.json();
405
406
		if (json.error) {
407
			throw new Error(`RPC error: ${json.error.message}`);
408
		}
409
410
		return json.result;
411
	}
412
413
	// Encoding and decoding methods
414
	encodeMethodSignature() {
415
		const inputs = this.methodData.inputs || [];
416
		const types = inputs.map((input) => input.type).join(",");
417
		const signature = `${this.methodName}(${types})`;
418
419
		// Use proper keccak256 hash - for production use a crypto library
420
		const hash = this.keccak256(signature);
421
		return "0x" + hash.slice(0, 8); // First 4 bytes (function selector)
422
	}
423
424
	encodeArguments() {
425
		const inputs = this.methodData.inputs || [];
426
427
		// If method has no inputs or no args provided, return empty string
428
		if (!inputs.length || !this.methodArgs.length) return "";
429
430
		// This is a simplified encoding - in production, use ethers.js or web3.js
431
		let encoded = "";
432
433
		for (let i = 0; i < this.methodArgs.length; i++) {
434
			const arg = this.methodArgs[i];
435
			const input = inputs[i];
436
437
			if (!input) continue;
438
439
			encoded += this.encodeValue(arg, input.type);
440
		}
441
442
		return encoded;
443
	}
444
445
	encodeValue(value, type) {
446
		// Simplified encoding - use proper ABI encoding library in production
447
		if (type === "uint256" || type === "uint") {
448
			return BigInt(value).toString(16).padStart(64, "0");
449
		} else if (type === "address") {
450
			return value.toLowerCase().replace("0x", "").padStart(64, "0");
451
		} else if (type === "string") {
452
			const hex = Array.from(new TextEncoder().encode(value))
453
				.map((b) => b.toString(16).padStart(2, "0"))
454
				.join("");
455
			return hex.padEnd(64, "0");
456
		}
457
		return "".padStart(64, "0");
458
	}
459
460
	decodeResult(result) {
461
		if (!result || result === "0x") return null;
462
463
		// Simplified decoding - use proper ABI decoding library in production
464
		const outputs = this.methodData.outputs || [];
465
		if (outputs.length === 0) return result;
466
467
		const output = outputs[0];
468
		const data = result.replace("0x", "");
469
470
		if (output.type === "uint256" || output.type === "uint") {
471
			return BigInt("0x" + data).toString();
472
		} else if (output.type === "address") {
473
			return "0x" + data.slice(-40);
474
		} else if (output.type === "string") {
475
			// Simplified string decoding
476
			try {
477
				const bytes =
478
					data.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) || [];
479
				return new TextDecoder().decode(new Uint8Array(bytes));
480
			} catch (e) {
481
				console.log(e);
482
				return data;
483
			}
484
		}
485
486
		return result;
487
	}
488
489
	keccak256(input) {
490
		const inputBytes = new TextEncoder().encode(input);
491
		const hashBytes = keccak_256(inputBytes);
492
		return Array.from(hashBytes)
493
			.map((b) => b.toString(16).padStart(2, "0"))
494
			.join("");
495
	}
496
497
	// UI helper methods
498
	getCSSVariable(name, defaultValue) {
499
		return this.getAttribute(name) || defaultValue;
500
	}
501
502
	getStatusColor() {
503
		if (this.error) return this.getCSSVariable("error-color", "#E78A53");
504
		if (this.result !== null)
505
			return this.getCSSVariable("success-color", "#5F8787");
506
		return this.getCSSVariable("primary", "#5F8787");
507
	}
508
509
	// Render methods
510
	render() {
511
		const background = this.getCSSVariable("background", "#232323");
512
		const foreground = this.getCSSVariable("foreground", "#ffffff");
513
		const primary = this.getCSSVariable("primary", "#5F8787");
514
		const borderRadius = this.getCSSVariable("border-radius", "4px");
515
516
		this.shadowRoot.innerHTML = `
517
			<style>
518
				:host {
519
					--norns-color-background: ${background};
520
					--norns-color-foreground: ${foreground};
521
					--norns-color-primary: ${primary};
522
					--norns-border-radius: ${borderRadius};
523
					display: inline-block;
524
					font-family: sans-serif;
525
				}
526
527
				.container {
528
					display: flex;
529
					flex-direction: column;
530
					gap: 12px;
531
					padding: 16px;
532
					background: var(--norns-color-background);
533
					border: 1px solid rgba(255, 255, 255, 0.1);
534
					border-radius: var(--norns-border-radius);
535
					color: var(--norns-color-foreground);
536
					width: 300px;
537
					box-sizing: border-box;
538
				}
539
540
				@media (max-width: 768px) {
541
					.container {
542
						width: 280px;
543
						padding: 12px;
544
						gap: 10px;
545
					}
546
				}
547
548
				@media (max-width: 480px) {
549
					.container {
550
						width: 260px;
551
						padding: 10px;
552
						gap: 8px;
553
					}
554
				}
555
556
				@media (max-width: 320px) {
557
					.container {
558
						width: 240px;
559
						padding: 8px;
560
						gap: 6px;
561
					}
562
				}
563
564
565
				.contract-info {
566
					display: flex;
567
					flex-direction: column;
568
					gap: 8px;
569
					font-size: 14px;
570
					opacity: 0.8;
571
				}
572
573
				.info-row {
574
					display: flex;
575
					flex-direction: column;
576
				}
577
578
				.info-label {
579
					font-weight: 600;
580
					margin-bottom: 4px;
581
				}
582
583
				.info-value {
584
					font-family: monospace;
585
					font-size: 12px;
586
					overflow: hidden;
587
					text-overflow: ellipsis;
588
					white-space: nowrap;
589
					max-width: 100%;
590
				}
591
592
				.status {
593
					font-size: 14px;
594
					font-family: monospace;
595
					word-break: break-all;
596
					color: ${this.getStatusColor()};
597
					overflow-wrap: break-word;
598
					white-space: pre-wrap;
599
				}
600
601
				.loading {
602
					display: flex;
603
					align-items: center;
604
					gap: 8px;
605
					color: var(--norns-color-primary);
606
				}
607
608
				.spinner {
609
					width: 16px;
610
					height: 16px;
611
					border: 2px solid rgba(255, 255, 255, 0.3);
612
					border-top: 2px solid var(--norns-color-foreground);
613
					border-radius: 50%;
614
					animation: spin 1s linear infinite;
615
				}
616
617
				@keyframes spin {
618
					from { transform: rotate(0deg); }
619
					to { transform: rotate(360deg); }
620
				}
621
622
				.error {
623
					color: ${this.getCSSVariable("error-color", "#E78A53")};
624
				}
625
626
				.success {
627
					color: ${this.getCSSVariable("success-color", "#5F8787")};
628
				}
629
630
				.result-label {
631
					font-weight: 600;
632
					margin-bottom: 4px;
633
					font-size: 14px;
634
				}
635
636
				.result-value {
637
					font-family: monospace;
638
					font-size: 14px;
639
					color: var(--norns-color-primary);
640
				}
641
			</style>
642
		`;
643
644
		const container = document.createElement("div");
645
		container.className = "container";
646
647
		// Loading state
648
		if (this.loading) {
649
			const loadingDiv = document.createElement("div");
650
			loadingDiv.className = "loading";
651
			loadingDiv.innerHTML = `
652
				<div class="spinner"></div>
653
				<span>Reading contract...</span>
654
			`;
655
			container.appendChild(loadingDiv);
656
		}
657
658
		// Result or error section
659
		if (this.error) {
660
			const errorDiv = document.createElement("div");
661
			errorDiv.className = "status error";
662
			errorDiv.textContent = `Error: ${this.error}`;
663
			container.appendChild(errorDiv);
664
		} else if (this.result !== null && !this.loading) {
665
			const resultDiv = document.createElement("div");
666
			resultDiv.className = "info-row";
667
			resultDiv.innerHTML = `
668
				<span class="result-label">${this.methodName}</span>
669
				<span class="result-value">${this.result}</span>
670
			`;
671
			container.appendChild(resultDiv);
672
		}
673
674
		this.shadowRoot.appendChild(container);
675
	}
676
}
677
678
customElements.define("contract-read", ContractRead);