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