Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Dogechain Super DApp - Multi-Wallet</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> | |
| <style> | |
| :root { --primary-color: #fbbf24; /* amber-400 */ } | |
| body { font-family: 'Inter', sans-serif; } | |
| .tab-button.active { | |
| background-color: var(--primary-color); | |
| color: #1f2937; /* gray-800 */ | |
| border-bottom-color: transparent; | |
| } | |
| .tab-button { | |
| transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; | |
| padding: 0.75rem 1.5rem; | |
| margin-bottom: -1px; /* Overlap border */ | |
| border: 1px solid #4b5563; /* gray-600 */ | |
| border-bottom: 1px solid #4b5563; | |
| white-space: nowrap; | |
| border-top-left-radius: 0.375rem; /* rounded-t-md */ | |
| border-top-right-radius: 0.375rem; /* rounded-t-md */ | |
| } | |
| .tab-button:not(.active):hover { background-color: #374151; /* gray-700 */ } | |
| .tab-content { display: none; } | |
| .tab-content.active { display: block; } | |
| .spinner { | |
| border: 4px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top-color: var(--primary-color); | |
| width: 20px; | |
| height: 20px; | |
| animation: spin 1s linear infinite; | |
| display: inline-block; | |
| vertical-align: middle; | |
| margin-right: 8px; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| input[type="text"], input[type="number"], textarea, select { | |
| background-color: #374151; /* gray-700 */ | |
| border: 1px solid #4b5563; /* gray-600 */ | |
| color: #f3f4f6; /* gray-100 */ | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 0.375rem; /* rounded-md */ | |
| width: 100%; | |
| } | |
| input:focus, textarea:focus, select:focus { | |
| outline: none; | |
| border-color: var(--primary-color); | |
| box-shadow: 0 0 0 2px rgba(251,191,36,0.5); | |
| } | |
| button { | |
| padding: 0.5rem 1rem; | |
| border-radius: 0.375rem; /* rounded-md */ | |
| font-weight: 600; /* semibold */ | |
| transition: all 0.2s; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| button:disabled { opacity: 0.6; cursor: not-allowed; } | |
| .btn-primary { | |
| background-color: var(--primary-color); | |
| color: #1f2937; /* gray-800 */ | |
| } | |
| .btn-primary:hover:not(:disabled) { background-color: #f59e0b; /* amber-500 */ } | |
| .btn-secondary { | |
| background-color: #4b5563; /* gray-600 */ | |
| color: #f3f4f6; /* gray-100 */ | |
| } | |
| .btn-secondary:hover:not(:disabled) { background-color: #6b7280; /* gray-500 */ } | |
| .btn-danger { | |
| background-color: #dc2626; /* red-600 */ | |
| color: #f3f4f6; /* gray-100 */ | |
| } | |
| .btn-danger:hover:not(:disabled) { background-color: #b91c1c; /* red-700 */ } | |
| .table-auto th, .table-auto td { | |
| border: 1px solid #4b5563; /* gray-600 */ | |
| padding: 0.5rem; | |
| text-align: left; | |
| } | |
| .table-auto th { background-color: #374151; /* gray-700 */ } | |
| .word-break-all { word-break: break-all; } | |
| /* Styles for Modals */ | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0, 0, 0, 0.7); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 1000; | |
| backdrop-filter: blur(4px); | |
| } | |
| .modal-content { | |
| position: relative; | |
| background-color: #1f2937; /* gray-800 */ | |
| padding: 1.5rem; | |
| border-radius: 0.5rem; | |
| box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); | |
| width: 100%; | |
| max-width: 24rem; /* sm */ | |
| text-align: center; | |
| border: 1px solid #4b5563; /* gray-600 */ | |
| } | |
| .modal-close-btn { | |
| position: absolute; | |
| top: 0.5rem; | |
| right: 0.75rem; | |
| background: none; | |
| border: none; | |
| font-size: 1.5rem; | |
| line-height: 1; | |
| color: #9ca3af; /* gray-400 */ | |
| cursor: pointer; | |
| padding: 0.25rem; | |
| } | |
| .modal-close-btn:hover { color: #f3f4f6; /* gray-100 */ } | |
| .wallet-choice-btn { | |
| text-align: left; | |
| background-color: #374151; /* gray-700 */ | |
| color: #f3f4f6; /* gray-100 */ | |
| border: 1px solid #4b5563; /* gray-600 */ | |
| padding: 1rem; | |
| font-size: 1rem; | |
| width: 100%; | |
| } | |
| .wallet-choice-btn:hover:not(:disabled) { border-color: var(--primary-color); background-color: #4b5563; } | |
| </style> | |
| <!-- Import Map for ethers v6 --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "ethers": "https://esm.sh/ethers@6", | |
| "react": "https://esm.sh/react@^19.1.0", | |
| "react-dom/": "https://esm.sh/react-dom@^19.1.0/", | |
| "react/": "https://esm.sh/react@^19.1.0/" | |
| } | |
| } | |
| </script> | |
| </head> | |
| <body class="bg-gray-900 text-gray-100 min-h-screen p-4 sm:p-6"> | |
| <div class="container mx-auto max-w-6xl"> | |
| <header class="mb-6 text-center"> | |
| <h1 class="text-3xl sm:text-4xl font-bold text-yellow-400">Dogechain Super DApp 🐕⛓️</h1> | |
| <p class="text-gray-400 mt-1">Multi-Wallet Support</p> | |
| <div class="mt-4"> | |
| <button id="connectWalletBtn" class="btn-primary px-5 py-2">Connect Wallet</button> | |
| <button id="disconnectWalletBtn" class="btn-secondary px-5 py-2 hidden ml-2">Disconnect</button> | |
| </div> | |
| <div id="walletInfo" class="mt-3 text-sm hidden space-y-1 bg-gray-800 p-3 rounded-md"> | |
| <p><strong>Status:</strong> <span id="walletStatus" class="text-green-400">Connected</span></p> | |
| <p><strong>Connected via:</strong> <span id="connectedWalletName" class="text-gray-300"></span></p> | |
| <p><strong>Account:</strong> <span id="connectedAccount" class="text-amber-300 word-break-all"></span></p> | |
| <p><strong>Network:</strong> <span id="connectedNetwork" class="text-amber-300"></span></p> | |
| <p><strong>Native Balance:</strong> <span id="accountBalance" class="text-amber-300"></span> DOGE</p> | |
| </div> | |
| </header> | |
| <!-- Global Message Area --> | |
| <div id="globalMessage" class="my-4 p-3 rounded-md text-white hidden break-words text-sm shadow-lg"></div> | |
| <!-- Tabs Navigation --> | |
| <nav class="mb-5"> | |
| <ul class="flex flex-wrap border-b border-gray-600 -mb-px"> | |
| <li><button class="tab-button active" data-tab="txidTools">TXID Tools</button></li> | |
| <li><button class="tab-button" data-tab="drc20Inspector">DRC-20 Inspector</button></li> | |
| <li><button class="tab-button" data-tab="contractInteract">Contract Interaction</button></li> | |
| </ul> | |
| </nav> | |
| <main id="tabContentContainer"> | |
| <!-- TXID Tools Tab --> | |
| <div id="txidTools" class="tab-content active space-y-6"> | |
| <section class="bg-gray-800 p-4 rounded-lg shadow"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">1. TXID Generator</h2> | |
| <button id="generateTxidBtn" class="btn-primary">Generate 64-char TXID</button> | |
| <input type="text" id="generatedTxid" class="mt-2" placeholder="Generated TXID will appear here" readonly> | |
| </section> | |
| <section class="bg-gray-800 p-4 rounded-lg shadow"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">2. TXID Signer</h2> | |
| <label for="txidToSign" class="block text-sm mb-1">TXID to Sign (auto-filled or paste):</label> | |
| <input type="text" id="txidToSign" class="mb-2" placeholder="Enter TXID (e.g., 0x...)"> | |
| <button id="signTxidBtn" class="btn-primary">Sign TXID with Wallet</button> | |
| <label for="txidSignature" class="block text-sm mt-3 mb-1">Signature:</label> | |
| <textarea id="txidSignature" rows="3" class="mb-2" placeholder="Signature will appear here" readonly></textarea> | |
| </section> | |
| <section class="bg-gray-800 p-4 rounded-lg shadow"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">3. Signature Verifier</h2> | |
| <label for="messageToVerify" class="block text-sm mb-1">Original Message (TXID):</label> | |
| <input type="text" id="messageToVerify" class="mb-2" placeholder="Enter original message (TXID)"> | |
| <label for="signatureToVerify" class="block text-sm mb-1">Signature:</label> | |
| <textarea id="signatureToVerify" rows="3" class="mb-2" placeholder="Enter signature"></textarea> | |
| <button id="verifySignatureBtn" class="btn-primary">Verify Signature</button> | |
| <p class="mt-2 text-sm">Recovered Address: <span id="recoveredAddress" class="font-mono text-amber-300 word-break-all"></span></p> | |
| <p id="verificationStatus" class="mt-1 text-sm"></p> | |
| </section> | |
| <section class="bg-gray-800 p-4 rounded-lg shadow"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">4. TXID + Signature History</h2> | |
| <button id="clearHistoryBtn" class="btn-danger text-sm mb-3">Clear History</button> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full table-auto text-sm"> | |
| <thead> | |
| <tr> | |
| <th>TXID/Message</th> | |
| <th>Signature</th> | |
| <th>Timestamp</th> | |
| <th>Verified Signer (If Known)</th> | |
| <th>Dogechain TX Status</th> | |
| </tr> | |
| </thead> | |
| <tbody id="txidHistoryTableBody"> | |
| <!-- History items will be populated here --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- DRC-20 Inspector Tab --> | |
| <div id="drc20Inspector" class="tab-content space-y-6"> | |
| <section class="bg-gray-800 p-4 rounded-lg shadow"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">DRC-20 Token Input</h2> | |
| <label for="drc20TokenAddress" class="block text-sm mb-1">DRC-20 Token Contract Address:</label> | |
| <input type="text" id="drc20TokenAddress" placeholder="Enter DRC-20 contract address (0x...)"> | |
| <button id="fetchTokenDataBtn" class="btn-primary mt-2">Fetch Token Data</button> | |
| </section> | |
| <section id="drc20MetadataSection" class="bg-gray-800 p-4 rounded-lg shadow hidden"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">Token Metadata</h2> | |
| <div id="drc20MetadataDisplay" class="space-y-1 text-sm"> | |
| <p><strong>Name:</strong> <span id="tokenMetaName">N/A</span></p> | |
| <p><strong>Symbol:</strong> <span id="tokenMetaSymbol">N/A</span></p> | |
| <p><strong>Decimals:</strong> <span id="tokenMetaDecimals">N/A</span></p> | |
| <p><strong>Total Supply:</strong> <span id="tokenMetaTotalSupply">N/A</span></p> | |
| </div> | |
| </section> | |
| <section id="drc20HoldersSection" class="bg-gray-800 p-4 rounded-lg shadow hidden"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">Token Holder Scanner</h2> | |
| <div class="flex justify-between items-center mb-2"> | |
| <label for="whaleFilterCheckbox" class="text-sm flex items-center"> | |
| <input type="checkbox" id="whaleFilterCheckbox" class="mr-2 h-4 w-4 rounded text-amber-400 focus:ring-amber-500 bg-gray-700 border-gray-600"> Show only "Whales" (10,000+ tokens) | |
| </label> | |
| <div id="holdersPaginationControls" class="text-sm"> | |
| <!-- Pagination buttons --> | |
| </div> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full table-auto text-sm"> | |
| <thead> | |
| <tr> | |
| <th>Holder Address</th> | |
| <th>Balance</th> | |
| <th>Percentage</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tokenHoldersTableBody"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <section id="drc20ChartSection" class="bg-gray-800 p-4 rounded-lg shadow hidden"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">Token Distribution Chart (Top Holders)</h2> | |
| <canvas id="tokenDistributionChart" class="max-h-96"></canvas> | |
| </section> | |
| <section id="drc20TransfersSection" class="bg-gray-800 p-4 rounded-lg shadow hidden"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">Recent Token Transfers</h2> | |
| <button id="toggleLiveUpdatesBtn" class="btn-secondary text-sm mb-2">Enable Live Updates</button> | |
| <div class="overflow-x-auto max-h-96"> | |
| <table class="w-full table-auto text-sm"> | |
| <thead> | |
| <tr> | |
| <th>TX Hash</th> | |
| <th>From</th> | |
| <th>To</th> | |
| <th>Amount</th> | |
| <th>Time</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tokenTransfersTableBody"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- Contract Interaction Tab --> | |
| <div id="contractInteract" class="tab-content space-y-6"> | |
| <section class="bg-gray-800 p-4 rounded-lg shadow"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">Contract Setup</h2> | |
| <label for="contractAddressInput" class="block text-sm mb-1">Contract Address:</label> | |
| <input type="text" id="contractAddressInput" class="mb-2" placeholder="Enter contract address (0x...)"> | |
| <label for="contractAbiInput" class="block text-sm mb-1">Contract ABI (JSON Array):</label> | |
| <textarea id="contractAbiInput" rows="5" class="mb-2" placeholder='[{"type":"constructor",...}, {"name":"myFunction",...}]'></textarea> | |
| <button id="loadContractBtn" class="btn-primary">Load Contract Functions</button> | |
| </section> | |
| <section id="contractFunctionsSection" class="bg-gray-800 p-4 rounded-lg shadow hidden"> | |
| <h2 class="text-xl font-semibold text-yellow-400 mb-3">Read Functions</h2> | |
| <div id="readFunctionsContainer" class="space-y-3"></div> | |
| <h2 class="text-xl font-semibold text-yellow-400 mt-6 mb-3">Write Functions</h2> | |
| <div id="writeFunctionsContainer" class="space-y-3"></div> | |
| </section> | |
| <div id="functionResultDisplay" class="mt-4 p-3 bg-gray-700 rounded-md text-sm hidden"> | |
| <h4 class="font-semibold mb-1">Result / TX Status:</h4> | |
| <pre id="functionResultOutput" class="whitespace-pre-wrap word-break-all"></pre> | |
| </div> | |
| </div> | |
| </main> | |
| <footer class="mt-12 text-center text-gray-500 text-xs pb-8"> | |
| <p id="footerYear"></p> | |
| <p>Always verify information and be cautious. Interacting with smart contracts carries inherent risk.</p> | |
| </footer> | |
| </div> | |
| <!-- Multi-Wallet Choice Modal --> | |
| <div id="walletChoiceModal" class="modal-overlay hidden"> | |
| <div class="modal-content"> | |
| <button id="walletChoiceModalCloseBtn" class="modal-close-btn">×</button> | |
| <h3 class="text-lg font-bold text-gray-100 mb-4">Connect a Wallet</h3> | |
| <div class="flex flex-col space-y-3"> | |
| <button id="connectMyDogeBtn" class="wallet-choice-btn"> | |
| <img src="https://mydoge.com/assets/img/logo_blue.svg" alt="MyDoge Logo" class="w-8 h-8 mr-4 inline-block align-middle"> | |
| MyDoge Wallet | |
| </button> | |
| <button id="connectMetaMaskBtn" class="wallet-choice-btn"> | |
| <img src="https://upload.wikimedia.org/wikipedia/commons/3/36/MetaMask_Fox.svg" alt="MetaMask Logo" class="w-8 h-8 mr-4 inline-block align-middle"> | |
| MetaMask / Other EIP-1193 | |
| </button> | |
| </div> | |
| <button id="walletChoiceCancelBtn" class="btn-secondary w-full py-2.5 mt-6">Cancel</button> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import { ethers } from 'ethers'; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // --- State Variables --- | |
| let provider; // ethers.BrowserProvider | |
| let signer; // ethers.Signer | |
| let currentAccount = null; // string | null | |
| let currentNetwork = null; // ethers.Network | null | |
| let currentWalletType = null; // 'mydoge' | 'metamask' | null | |
| let tokenDistributionChartInstance = null; // Chart.js instance | |
| let transferPollInterval; // For live transfer updates | |
| let activeTokenAddress = null; // For DRC-20 Inspector | |
| let currentHoldersPage = 1; | |
| let isLiveUpdatesEnabled = false; | |
| // --- Constants --- | |
| const DOGECHAIN_MAINNET_CHAIN_ID = 2000n; // Use BigInt for chain IDs with ethers v6 | |
| const DOGECHAIN_TESTNET_CHAIN_ID = 568n; // Example Testnet ID | |
| const DOGECHAIN_EXPLORER_API_BASE = 'https://explorer.dogechain.dog/api'; | |
| const TXID_HISTORY_KEY = 'dogechainSuperDapp_TxidHistory_v4_multiwallet'; | |
| // --- UI Element Selectors --- | |
| const getEl = (id) => document.getElementById(id); | |
| const connectWalletBtn = getEl('connectWalletBtn'); | |
| const disconnectWalletBtn = getEl('disconnectWalletBtn'); | |
| const walletInfoDiv = getEl('walletInfo'); | |
| const walletStatusSpan = getEl('walletStatus'); | |
| const connectedWalletNameSpan = getEl('connectedWalletName'); | |
| const connectedAccountSpan = getEl('connectedAccount'); | |
| const connectedNetworkSpan = getEl('connectedNetwork'); | |
| const accountBalanceSpan = getEl('accountBalance'); | |
| const globalMessageDiv = getEl('globalMessage'); | |
| // Wallet Choice Modal | |
| const walletChoiceModal = getEl('walletChoiceModal'); | |
| const connectMyDogeBtn = getEl('connectMyDogeBtn'); | |
| const connectMetaMaskBtn = getEl('connectMetaMaskBtn'); | |
| const walletChoiceCancelBtn = getEl('walletChoiceCancelBtn'); | |
| const walletChoiceModalCloseBtn = getEl('walletChoiceModalCloseBtn'); | |
| // Tab Elements | |
| const tabButtons = document.querySelectorAll('.tab-button'); | |
| const tabContents = document.querySelectorAll('.tab-content'); | |
| // TXID Tools Elements | |
| const generateTxidBtn = getEl('generateTxidBtn'); | |
| const generatedTxidInput = getEl('generatedTxid'); | |
| const txidToSignInput = getEl('txidToSign'); | |
| const signTxidBtn = getEl('signTxidBtn'); | |
| const txidSignatureTextarea = getEl('txidSignature'); | |
| const messageToVerifyInput = getEl('messageToVerify'); | |
| const signatureToVerifyTextarea = getEl('signatureToVerify'); | |
| const verifySignatureBtn = getEl('verifySignatureBtn'); | |
| const recoveredAddressSpan = getEl('recoveredAddress'); | |
| const verificationStatusP = getEl('verificationStatus'); | |
| const clearHistoryBtn = getEl('clearHistoryBtn'); | |
| const txidHistoryTableBody = getEl('txidHistoryTableBody'); | |
| // DRC-20 Inspector Elements | |
| const drc20TokenAddressInput = getEl('drc20TokenAddress'); | |
| const fetchTokenDataBtn = getEl('fetchTokenDataBtn'); | |
| const drc20MetadataSection = getEl('drc20MetadataSection'); | |
| const tokenMetaNameSpan = getEl('tokenMetaName'); | |
| const tokenMetaSymbolSpan = getEl('tokenMetaSymbol'); | |
| const tokenMetaDecimalsSpan = getEl('tokenMetaDecimals'); | |
| const tokenMetaTotalSupplySpan = getEl('tokenMetaTotalSupply'); | |
| const drc20HoldersSection = getEl('drc20HoldersSection'); | |
| const tokenHoldersTableBody = getEl('tokenHoldersTableBody'); | |
| const holdersPaginationControlsDiv = getEl('holdersPaginationControls'); | |
| const whaleFilterCheckbox = getEl('whaleFilterCheckbox'); | |
| const drc20ChartSection = getEl('drc20ChartSection'); | |
| const tokenDistributionChartCanvas = getEl('tokenDistributionChart'); | |
| const drc20TransfersSection = getEl('drc20TransfersSection'); | |
| const tokenTransfersTableBody = getEl('tokenTransfersTableBody'); | |
| const toggleLiveUpdatesBtn = getEl('toggleLiveUpdatesBtn'); | |
| // Contract Interaction Elements | |
| const contractAddressInput = getEl('contractAddressInput'); | |
| const contractAbiInput = getEl('contractAbiInput'); | |
| const loadContractBtn = getEl('loadContractBtn'); | |
| const contractFunctionsSection = getEl('contractFunctionsSection'); | |
| const readFunctionsContainer = getEl('readFunctionsContainer'); | |
| const writeFunctionsContainer = getEl('writeFunctionsContainer'); | |
| const functionResultDisplay = getEl('functionResultDisplay'); | |
| const functionResultOutput = getEl('functionResultOutput'); | |
| let currentContractInstance; // Stores the ethers.Contract instance | |
| // Footer year | |
| getEl('footerYear').textContent = `© ${new Date().getFullYear()} Dogechain Super DApp. For educational purposes.`; | |
| // --- Helper Functions --- | |
| function showMessage(message, type = 'info', duration = 6000) { | |
| globalMessageDiv.textContent = message; | |
| globalMessageDiv.className = 'my-4 p-3 rounded-md text-white break-words text-sm shadow-lg'; // Reset classes | |
| const typeClasses = { error: 'bg-red-600', success: 'bg-green-600', info: 'bg-blue-600' }; | |
| globalMessageDiv.classList.add(typeClasses[type] || 'bg-blue-600'); | |
| globalMessageDiv.classList.remove('hidden'); | |
| setTimeout(() => { | |
| if (globalMessageDiv.textContent === message) { | |
| globalMessageDiv.classList.add('hidden'); | |
| } | |
| }, duration); | |
| } | |
| function showLoadingSpinner(button, show = true, originalText = "Submit") { | |
| if (!button) return; | |
| if (show) { | |
| button.disabled = true; | |
| button.dataset.originalText = button.innerHTML; | |
| button.innerHTML = `<span class="spinner"></span>Processing...`; | |
| } else { | |
| button.disabled = false; | |
| button.innerHTML = button.dataset.originalText || originalText; | |
| } | |
| } | |
| async function checkDogechainTxStatus(txHash) { | |
| // ... (implementation is the same as previous full Vanilla JS version) | |
| try { | |
| const response = await fetch(`${DOGECHAIN_EXPLORER_API_BASE}?module=transaction&action=gettxreceiptstatus&txhash=${txHash}`); | |
| if (!response.ok) throw new Error(`API request failed with status ${response.status}`); | |
| const data = await response.json(); | |
| if (data.status === "1" && data.result && data.result.status === "1") { | |
| return '<span class="text-green-400">Success</span>'; | |
| } else if (data.status === "1" && data.result && data.result.status === "0") { | |
| return '<span class="text-red-400">Failed</span>'; | |
| } else { | |
| return '<span class="text-yellow-400">Pending/Unknown</span>'; | |
| } | |
| } catch (error) { | |
| console.warn(`Failed to fetch status for TX ${txHash}:`, error); | |
| return 'Error fetching'; | |
| } | |
| } | |
| // --- UNIFIED WALLET CONNECTION LOGIC --- | |
| connectWalletBtn.addEventListener('click', () => { | |
| walletChoiceModal.classList.remove('hidden'); | |
| }); | |
| walletChoiceCancelBtn.addEventListener('click', () => { | |
| walletChoiceModal.classList.add('hidden'); | |
| }); | |
| walletChoiceModalCloseBtn.addEventListener('click', () => { | |
| walletChoiceModal.classList.add('hidden'); | |
| }); | |
| connectMyDogeBtn.addEventListener('click', () => handleWalletConnectionAttempt('mydoge')); | |
| connectMetaMaskBtn.addEventListener('click', () => handleWalletConnectionAttempt('metamask')); | |
| disconnectWalletBtn.addEventListener('click', () => { | |
| handleDisconnect(); | |
| }); | |
| async function handleWalletConnectionAttempt(walletTypeToConnect) { | |
| currentWalletType = walletTypeToConnect; // Store the type for re-connection attempts | |
| let walletProviderInstance; | |
| let walletDisplayName; | |
| if (walletTypeToConnect === 'mydoge') { | |
| if (!window.myDogeEthereum) { // MyDoge specific provider check | |
| showMessage("MyDoge Wallet extension not found. Please install it.", 'error'); | |
| return; | |
| } | |
| walletProviderInstance = window.myDogeEthereum; | |
| walletDisplayName = "MyDoge Wallet"; | |
| } else { // 'metamask' or other EIP-1193 standard wallets | |
| if (!window.ethereum) { | |
| showMessage("MetaMask or a compatible EIP-1193 wallet not found.", 'error'); | |
| return; | |
| } | |
| walletProviderInstance = window.ethereum; | |
| // Determine name more specifically if possible | |
| if (window.ethereum.isMetaMask) walletDisplayName = "MetaMask"; | |
| else if (window.ethereum.isMyDoge) { // Should not happen if MyDoge button was clicked, but good fallback | |
| walletDisplayName = "MyDoge Wallet (via window.ethereum)"; | |
| } | |
| else walletDisplayName = "EIP-1193 Wallet"; | |
| } | |
| walletChoiceModal.classList.add('hidden'); | |
| showLoadingSpinner(connectWalletBtn, true, "Connect Wallet"); | |
| try { | |
| provider = new ethers.BrowserProvider(walletProviderInstance, 'any'); | |
| // Request accounts - this triggers the wallet prompt | |
| const accounts = await provider.send("eth_requestAccounts", []); | |
| if (!accounts || accounts.length === 0) { | |
| throw new Error("No accounts selected or connection rejected by user."); | |
| } | |
| signer = await provider.getSigner(); // Get signer for the selected account | |
| currentAccount = await signer.getAddress(); | |
| currentNetwork = await provider.getNetwork(); | |
| updateWalletUI(currentAccount, currentNetwork, walletDisplayName); | |
| setupWalletListeners(walletProviderInstance); // Pass the specific provider instance | |
| showMessage(`${walletDisplayName} connected successfully!`, 'success'); | |
| // Recommend switching if not on Dogechain Mainnet (adjust as needed for testnet focus) | |
| if (currentNetwork.chainId !== DOGECHAIN_MAINNET_CHAIN_ID) { | |
| // Could add logic here to prompt for switch to DOGECHAIN_TESTNET_CHAIN_ID if this dApp is for testnet | |
| showMessage(`Warning: You are currently on ${currentNetwork.name}. This DApp is primarily tested on Dogechain Mainnet (ID: ${DOGECHAIN_MAINNET_CHAIN_ID}).`, 'error', 10000); | |
| } | |
| } catch (error) { | |
| console.error(`Error connecting ${walletDisplayName}:`, error); | |
| showMessage(`Connection failed: ${error.message || 'User rejected the request.'}`, 'error'); | |
| handleDisconnect(); // Ensure UI resets | |
| } finally { | |
| showLoadingSpinner(connectWalletBtn, false, "Connect Wallet"); | |
| } | |
| } | |
| function setupWalletListeners(activeProvider) { | |
| if (activeProvider && typeof activeProvider.on === 'function' && typeof activeProvider.removeListener === 'function') { | |
| // Clear previous listeners from this specific provider to avoid duplicates | |
| activeProvider.removeListener('accountsChanged', handleAccountsChanged); | |
| activeProvider.removeListener('chainChanged', handleChainChanged); | |
| activeProvider.on('accountsChanged', handleAccountsChanged); | |
| activeProvider.on('chainChanged', handleChainChanged); | |
| } | |
| } | |
| async function handleAccountsChanged(accounts) { | |
| if (accounts.length === 0) { | |
| showMessage("Wallet disconnected or locked.", "info"); | |
| handleDisconnect(); | |
| } else if (accounts[0].toLowerCase() !== currentAccount?.toLowerCase()) { | |
| showMessage(`Account switched in ${currentWalletType === 'mydoge' ? 'MyDoge Wallet' : 'wallet'}. Re-initializing...`, "info"); | |
| // Attempt to re-establish connection state with the new account using the stored wallet type | |
| if (currentWalletType) { | |
| await handleWalletConnectionAttempt(currentWalletType); | |
| } else { // Should not happen if currentWalletType is set on connect | |
| handleDisconnect(); | |
| } | |
| } | |
| } | |
| function handleChainChanged(_chainId) { | |
| showMessage(`Network changed in wallet. Reloading DApp to reflect new network state...`, "info"); | |
| setTimeout(() => window.location.reload(), 1500); | |
| } | |
| function handleDisconnect() { | |
| // Remove listeners from the specific provider if it's known and supports removeListener | |
| let activeProvider = null; | |
| if (currentWalletType === 'mydoge' && window.myDogeEthereum) activeProvider = window.myDogeEthereum; | |
| else if (window.ethereum) activeProvider = window.ethereum; | |
| if (activeProvider && typeof activeProvider.removeListener === 'function') { | |
| activeProvider.removeListener('accountsChanged', handleAccountsChanged); | |
| activeProvider.removeListener('chainChanged', handleChainChanged); | |
| } | |
| provider = null; | |
| signer = null; | |
| currentAccount = null; | |
| currentNetwork = null; | |
| currentWalletType = null; // Reset wallet type | |
| walletInfoDiv.classList.add('hidden'); | |
| connectWalletBtn.textContent = 'Connect Wallet'; | |
| connectWalletBtn.disabled = false; | |
| connectWalletBtn.classList.remove('hidden'); | |
| disconnectWalletBtn.classList.add('hidden'); | |
| if (walletStatusSpan) walletStatusSpan.textContent = 'Not Connected'; | |
| if (walletStatusSpan) walletStatusSpan.className = 'text-red-400'; | |
| if (accountBalanceSpan) accountBalanceSpan.textContent = '0.00'; | |
| if (connectedWalletNameSpan) connectedWalletNameSpan.textContent = 'N/A'; | |
| if (transferPollInterval) clearInterval(transferPollInterval); | |
| isLiveUpdatesEnabled = false; | |
| if (toggleLiveUpdatesBtn) { | |
| toggleLiveUpdatesBtn.textContent = "Enable Live Updates"; | |
| toggleLiveUpdatesBtn.classList.remove('btn-danger'); | |
| } | |
| showMessage("Wallet disconnected.", "info"); | |
| } | |
| async function updateWalletUI(account, network, walletName) { | |
| if (!account || !network || !provider) { | |
| handleDisconnect(); return; | |
| } | |
| connectedWalletNameSpan.textContent = walletName; | |
| connectedAccountSpan.textContent = account; | |
| connectedNetworkSpan.textContent = `${network.name} (ID: ${network.chainId.toString()})`; | |
| walletStatusSpan.textContent = 'Connected'; | |
| walletStatusSpan.className = 'text-green-400'; | |
| walletInfoDiv.classList.remove('hidden'); | |
| connectWalletBtn.classList.add('hidden'); | |
| disconnectWalletBtn.classList.remove('hidden'); | |
| try { | |
| const balanceWei = await provider.getBalance(account); | |
| accountBalanceSpan.textContent = parseFloat(ethers.formatEther(balanceWei)).toFixed(4); | |
| } catch (error) { | |
| console.error("Error fetching balance:", error); | |
| accountBalanceSpan.textContent = "Error"; | |
| } | |
| } | |
| // --- Tab Management --- | |
| tabButtons.forEach(button => { | |
| button.addEventListener('click', () => { | |
| const activeTab = button.dataset.tab; | |
| tabButtons.forEach(btn => btn.classList.remove('active')); | |
| tabContents.forEach(content => content.classList.remove('active')); | |
| button.classList.add('active'); | |
| getEl(activeTab).classList.add('active'); | |
| if (activeTab === 'drc20Inspector' && tokenDistributionChartInstance) { | |
| setTimeout(() => tokenDistributionChartInstance.resize(), 0); | |
| } | |
| }); | |
| }); | |
| // --- TXID Tools Module --- | |
| generateTxidBtn.addEventListener('click', () => { | |
| const randomBytes = ethers.randomBytes(32); | |
| const txid = ethers.hexlify(randomBytes); | |
| generatedTxidInput.value = txid; | |
| txidToSignInput.value = txid; | |
| messageToVerifyInput.value = txid; | |
| showMessage('TXID generated and auto-filled.', 'success'); | |
| }); | |
| signTxidBtn.addEventListener('click', async () => { | |
| if (!signer) { showMessage('Connect wallet first.', 'error'); return; } | |
| const message = txidToSignInput.value; | |
| if (!message || !ethers.isHexString(message, 32)) { | |
| showMessage('Enter a valid 64-character 0x-prefixed TXID.', 'error'); return; | |
| } | |
| const originalText = signTxidBtn.textContent; | |
| showLoadingSpinner(signTxidBtn, true, originalText); | |
| try { | |
| const signature = await signer.signMessage(ethers.getBytes(message)); // Ethers v6 signs bytes | |
| txidSignatureTextarea.value = signature; | |
| signatureToVerifyTextarea.value = signature; | |
| showMessage('Message signed!', 'success'); | |
| saveTxidToHistory(message, signature, currentAccount); | |
| } catch (error) { | |
| console.error("Sign error:", error); | |
| showMessage(`Sign error: ${error.message}`, 'error'); | |
| } finally { | |
| showLoadingSpinner(signTxidBtn, false, originalText); | |
| } | |
| }); | |
| verifySignatureBtn.addEventListener('click', () => { | |
| const message = messageToVerifyInput.value; | |
| const signature = signatureToVerifyTextarea.value; | |
| if (!message || !signature) { showMessage('Message and signature required.', 'error'); return; } | |
| if (!ethers.isHexString(message, 32)) { showMessage('Original message (TXID) must be 0x-prefixed 64-char hex.', 'error'); return; } | |
| if (!ethers.isHexString(signature)) { showMessage('Signature must be 0x-prefixed hex.', 'error'); return; } | |
| try { | |
| const recovered = ethers.verifyMessage(ethers.getBytes(message), signature); | |
| recoveredAddressSpan.textContent = recovered; | |
| verificationStatusP.className = 'mt-1 text-sm'; | |
| if (currentAccount && recovered.toLowerCase() === currentAccount.toLowerCase()) { | |
| verificationStatusP.textContent = 'OK! Signature matches connected wallet.'; | |
| verificationStatusP.classList.add('text-green-400'); | |
| } else if (currentAccount) { | |
| verificationStatusP.textContent = 'Valid signature, but not from connected wallet.'; | |
| verificationStatusP.classList.add('text-yellow-400'); | |
| } else { | |
| verificationStatusP.textContent = 'Valid signature.'; | |
| verificationStatusP.classList.add('text-green-400'); | |
| } | |
| } catch (error) { | |
| console.error("Verify error:", error); | |
| recoveredAddressSpan.textContent = 'Error'; | |
| verificationStatusP.textContent = `Verification failed: ${error.message}`; | |
| verificationStatusP.className = 'mt-1 text-sm text-red-400'; | |
| } | |
| }); | |
| function saveTxidToHistory(message, signature, verifiedSigner = null) { | |
| let history = JSON.parse(localStorage.getItem(TXID_HISTORY_KEY)) || []; | |
| const isTxHash = ethers.isHexString(message, 32); | |
| const newEntry = { | |
| message, signature, verifiedSigner, | |
| timestamp: new Date().toISOString(), | |
| dogechainTxStatus: isTxHash ? 'Checking...' : 'N/A (Not TX Hash)' | |
| }; | |
| history.unshift(newEntry); | |
| if (history.length > 50) history.pop(); | |
| localStorage.setItem(TXID_HISTORY_KEY, JSON.stringify(history)); | |
| renderTxidHistory(); | |
| } | |
| async function renderTxidHistory() { | |
| // ... (implementation is the same as previous full Vanilla JS version, check statusCell logic) | |
| txidHistoryTableBody.innerHTML = ''; | |
| let history = JSON.parse(localStorage.getItem(TXID_HISTORY_KEY)) || []; | |
| if (history.length === 0) { | |
| txidHistoryTableBody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-gray-400">No history yet.</td></tr>'; | |
| return; | |
| } | |
| for (const item of history) { | |
| const row = txidHistoryTableBody.insertRow(); | |
| row.innerHTML = ` | |
| <td class="word-break-all text-xs">${item.message}</td> | |
| <td class="word-break-all text-xs">${item.signature}</td> | |
| <td class="text-xs">${new Date(item.timestamp).toLocaleString()}</td> | |
| <td class="word-break-all text-xs">${item.verifiedSigner || 'N/A'}</td> | |
| <td class="text-xs status-cell" data-txhash="${item.message}">${item.dogechainTxStatus || 'N/A'}</td> | |
| `; | |
| if (item.dogechainTxStatus === 'Checking...' && ethers.isHexString(item.message, 32)) { | |
| const statusCell = row.querySelector('.status-cell'); | |
| checkDogechainTxStatus(item.message).then(statusHtml => { | |
| if (statusCell) statusCell.innerHTML = statusHtml; | |
| const idx = history.findIndex(h => h.timestamp === item.timestamp && h.message === item.message); | |
| if(idx !== -1) { | |
| history[idx].dogechainTxStatus = statusHtml.replace(/<[^>]*>?/gm, ''); | |
| localStorage.setItem(TXID_HISTORY_KEY, JSON.stringify(history)); | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| clearHistoryBtn.addEventListener('click', () => { | |
| if (confirm('Clear all TXID history?')) { | |
| localStorage.removeItem(TXID_HISTORY_KEY); | |
| renderTxidHistory(); | |
| showMessage('TXID history cleared.', 'success'); | |
| } | |
| }); | |
| // --- DRC-20 Inspector Module --- | |
| fetchTokenDataBtn.addEventListener('click', async () => { | |
| const tokenAddr = drc20TokenAddressInput.value.trim(); | |
| if (!ethers.isAddress(tokenAddr)) { showMessage("Invalid DRC-20 Address.", "error"); return; } | |
| activeTokenAddress = tokenAddr; | |
| showMessage(`Fetching data for ${activeTokenAddress.substring(0,10)}...`, "info", 3000); | |
| drc20MetadataSection.classList.add('hidden'); | |
| drc20HoldersSection.classList.add('hidden'); | |
| drc20ChartSection.classList.add('hidden'); | |
| drc20TransfersSection.classList.add('hidden'); | |
| tokenHoldersTableBody.innerHTML = '<tr><td colspan="3" class="text-center py-3 text-gray-400"><span class="spinner"></span> Loading holders...</td></tr>'; | |
| tokenTransfersTableBody.innerHTML = '<tr><td colspan="5" class="text-center py-3 text-gray-400"><span class="spinner"></span> Loading transfers...</td></tr>'; | |
| if (tokenDistributionChartInstance) tokenDistributionChartInstance.destroy(); | |
| await fetchTokenMetadata(activeTokenAddress); | |
| if (!drc20MetadataSection.classList.contains('hidden') && tokenMetaDecimalsSpan.textContent !== 'Error' && tokenMetaDecimalsSpan.textContent !== 'N/A') { | |
| await fetchTokenHolders(activeTokenAddress, 1); | |
| await fetchTokenTransfers(activeTokenAddress); | |
| } else { | |
| showMessage("Cannot fetch holders/transfers without token metadata.", "error"); | |
| } | |
| }); | |
| whaleFilterCheckbox.addEventListener('change', () => { | |
| if (activeTokenAddress && !drc20MetadataSection.classList.contains('hidden') && tokenMetaDecimalsSpan.textContent !== 'Error' && tokenMetaDecimalsSpan.textContent !== 'N/A') { | |
| fetchTokenHolders(activeTokenAddress, 1); | |
| } | |
| }); | |
| toggleLiveUpdatesBtn.addEventListener('click', () => { | |
| isLiveUpdatesEnabled = !isLiveUpdatesEnabled; | |
| toggleLiveUpdatesBtn.textContent = isLiveUpdatesEnabled ? "Disable Live Updates" : "Enable Live Updates"; | |
| toggleLiveUpdatesBtn.classList.toggle('btn-danger', isLiveUpdatesEnabled); | |
| if (isLiveUpdatesEnabled && activeTokenAddress && !drc20MetadataSection.classList.contains('hidden') && tokenMetaDecimalsSpan.textContent !== 'Error' && tokenMetaDecimalsSpan.textContent !== 'N/A') { | |
| fetchTokenTransfers(activeTokenAddress); | |
| showMessage("Live transfer updates enabled.", "success"); | |
| } else { | |
| if (transferPollInterval) clearInterval(transferPollInterval); | |
| showMessage("Live transfer updates disabled.", "info"); | |
| if (!activeTokenAddress || drc20MetadataSection.classList.contains('hidden') || tokenMetaDecimalsSpan.textContent === 'Error' || tokenMetaDecimalsSpan.textContent === 'N/A') { | |
| showMessage("Cannot enable live updates without a valid token and its metadata.", "error"); | |
| isLiveUpdatesEnabled = false; | |
| toggleLiveUpdatesBtn.textContent = "Enable Live Updates"; | |
| toggleLiveUpdatesBtn.classList.remove('btn-danger'); | |
| } | |
| } | |
| }); | |
| async function fetchTokenMetadata(tokenAddress) { | |
| // ... (implementation is the same, using ethers.Contract with provider) | |
| if (!provider) { | |
| showMessage("Connect wallet for provider.", "error"); | |
| tokenMetaNameSpan.textContent = "Error"; tokenMetaSymbolSpan.textContent = "Error"; | |
| tokenMetaDecimalsSpan.textContent = "Error"; tokenMetaTotalSupplySpan.textContent = "Error"; | |
| drc20MetadataSection.classList.remove('hidden'); return; | |
| } | |
| const erc20Abi = ["function name() view returns (string)", "function symbol() view returns (string)", "function decimals() view returns (uint8)", "function totalSupply() view returns (uint256)"]; | |
| const originalButtonText = fetchTokenDataBtn.textContent; | |
| showLoadingSpinner(fetchTokenDataBtn, true, originalButtonText); | |
| try { | |
| const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, provider); | |
| const [name, symbol, decimalsResponse, totalSupplyResponse] = await Promise.all([ | |
| tokenContract.name(), tokenContract.symbol(), tokenContract.decimals(), tokenContract.totalSupply() | |
| ]); | |
| const decimals = Number(decimalsResponse); // decimals() in ERC20 usually returns uint8 | |
| tokenMetaNameSpan.textContent = name; | |
| tokenMetaSymbolSpan.textContent = symbol; | |
| tokenMetaDecimalsSpan.textContent = decimals.toString(); | |
| tokenMetaTotalSupplySpan.textContent = ethers.formatUnits(totalSupplyResponse, decimals); | |
| drc20MetadataSection.classList.remove('hidden'); | |
| showMessage("Token metadata fetched.", "success", 3000); | |
| } catch (error) { | |
| console.error("Fetch metadata error:", error); | |
| showMessage("Failed to fetch metadata. Valid DRC-20?", "error"); | |
| tokenMetaNameSpan.textContent = "Error"; tokenMetaSymbolSpan.textContent = "Error"; | |
| tokenMetaDecimalsSpan.textContent = "Error"; tokenMetaTotalSupplySpan.textContent = "Error"; | |
| drc20MetadataSection.classList.remove('hidden'); | |
| } finally { | |
| showLoadingSpinner(fetchTokenDataBtn, false, originalButtonText); | |
| } | |
| } | |
| async function fetchTokenHolders(tokenAddress, page, offset = 10) { | |
| // ... (implementation is the same, verify ethers.parseUnits and ethers.formatUnits) | |
| currentHoldersPage = page; | |
| const isWhaleFilterActive = whaleFilterCheckbox.checked; | |
| tokenHoldersTableBody.innerHTML = '<tr><td colspan="3" class="text-center py-3 text-gray-400"><span class="spinner"></span> Loading...</td></tr>'; | |
| try { | |
| const response = await fetch(`${DOGECHAIN_EXPLORER_API_BASE}?module=account&action=tokentx&contractaddress=${tokenAddress}&page=1&offset=1000&sort=desc`); | |
| if (!response.ok) throw new Error(`API error: ${response.status}`); | |
| const data = await response.json(); | |
| if (data.status === "1" && Array.isArray(data.result)) { | |
| let holders = new Map(); | |
| const decimalsText = tokenMetaDecimalsSpan.textContent; | |
| if (decimalsText === 'Error' || decimalsText === 'N/A' || isNaN(parseInt(decimalsText))) { | |
| throw new Error("Token decimals not available for balance calculation."); | |
| } | |
| const decimals = parseInt(decimalsText); | |
| data.result.forEach(tx => { | |
| const valueInSmallestUnit = ethers.toBigInt(tx.value); | |
| if (tx.to && ethers.isAddress(tx.to)) { | |
| const toLower = tx.to.toLowerCase(); | |
| holders.set(toLower, (holders.get(toLower) || 0n) + valueInSmallestUnit); | |
| } | |
| }); | |
| let aggregatedHolders = Array.from(holders.entries()) | |
| .map(([address, balanceSmallestUnit]) => ({ address, balanceSmallestUnit })) | |
| .filter(h => h.balanceSmallestUnit > 0n); | |
| if (isWhaleFilterActive) { | |
| const whaleThreshold = ethers.parseUnits("10000", decimals); | |
| aggregatedHolders = aggregatedHolders.filter(h => h.balanceSmallestUnit >= whaleThreshold); | |
| } | |
| aggregatedHolders.sort((a, b) => { // BigInt safe sort | |
| if (a.balanceSmallestUnit < b.balanceSmallestUnit) return 1; | |
| if (a.balanceSmallestUnit > b.balanceSmallestUnit) return -1; | |
| return 0; | |
| }); | |
| const paginatedHolders = aggregatedHolders.slice((page - 1) * offset, page * offset); | |
| tokenHoldersTableBody.innerHTML = ''; | |
| if (paginatedHolders.length === 0) { | |
| tokenHoldersTableBody.innerHTML = '<tr><td colspan="3" class="text-center text-gray-400">No holders found.</td></tr>'; | |
| } else { | |
| const totalSupplyStr = tokenMetaTotalSupplySpan.textContent.replace(/,/g, ''); | |
| let totalSupplySmallestUnit; | |
| try { totalSupplySmallestUnit = ethers.parseUnits(totalSupplyStr, decimals); } | |
| catch { totalSupplySmallestUnit = 0n; } | |
| paginatedHolders.forEach(holder => { | |
| const balanceFormatted = ethers.formatUnits(holder.balanceSmallestUnit, decimals); | |
| const percentage = totalSupplySmallestUnit > 0n ? (Number(holder.balanceSmallestUnit * 10000n / totalSupplySmallestUnit) / 100).toFixed(4) : "0.0000"; | |
| const row = tokenHoldersTableBody.insertRow(); | |
| row.innerHTML = `<td class="word-break-all text-xs">${holder.address}</td><td class="text-xs">${parseFloat(balanceFormatted).toFixed(4)}</td><td class="text-xs">${percentage}%</td>`; | |
| }); | |
| } | |
| drc20HoldersSection.classList.remove('hidden'); | |
| renderHoldersPagination(page, aggregatedHolders.length, offset); | |
| renderTokenDistributionChart(aggregatedHolders.slice(0,10), decimals); | |
| } else { | |
| showMessage("Could not fetch holders: " + (data.message || "API issue"), "error"); | |
| tokenHoldersTableBody.innerHTML = '<tr><td colspan="3" class="text-center text-gray-400">Error fetching.</td></tr>'; | |
| } | |
| } catch (error) { | |
| console.error("Fetch holders error:", error); | |
| showMessage("Fetch holders error: " + error.message, "error"); | |
| tokenHoldersTableBody.innerHTML = '<tr><td colspan="3" class="text-center text-gray-400">Error fetching.</td></tr>'; | |
| } | |
| } | |
| function renderHoldersPagination(currentPage, totalItems, itemsPerPage) { | |
| // ... (implementation is the same) | |
| holdersPaginationControlsDiv.innerHTML = ''; | |
| const totalPages = Math.ceil(totalItems / itemsPerPage); | |
| if (totalPages <= 1) return; | |
| const createButton = (text, pageNum, isDisabled) => { | |
| const button = document.createElement('button'); | |
| button.textContent = text; | |
| button.className = 'btn-secondary text-xs mx-1 disabled:opacity-50'; | |
| button.disabled = isDisabled; | |
| button.addEventListener('click', () => fetchTokenHolders(activeTokenAddress, pageNum)); | |
| return button; | |
| }; | |
| holdersPaginationControlsDiv.appendChild(createButton('Previous', currentPage - 1, currentPage === 1)); | |
| const pageInfo = document.createElement('span'); pageInfo.textContent = ` Page ${currentPage} of ${totalPages} `; pageInfo.className = 'text-xs align-middle'; | |
| holdersPaginationControlsDiv.appendChild(pageInfo); | |
| holdersPaginationControlsDiv.appendChild(createButton('Next', currentPage + 1, currentPage === totalPages)); | |
| } | |
| function renderTokenDistributionChart(topHolders, decimals) { | |
| // ... (implementation is the same) | |
| if (tokenDistributionChartInstance) tokenDistributionChartInstance.destroy(); | |
| if (!topHolders || topHolders.length === 0) { drc20ChartSection.classList.add('hidden'); return; } | |
| const labels = topHolders.map(h => `${h.address.substring(0,6)}...${h.address.substring(h.address.length - 4)}`); | |
| const data = topHolders.map(h => parseFloat(ethers.formatUnits(h.balanceSmallestUnit, decimals))); | |
| const chartData = { | |
| labels: labels, | |
| datasets: [{ label: 'Token Distribution', data: data, backgroundColor: ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#7BC225','#F263A2','#A136EB'], hoverOffset: 4, borderColor: '#374151', borderWidth: 1 }] | |
| }; | |
| tokenDistributionChartInstance = new Chart(tokenDistributionChartCanvas, { type: 'pie', data: chartData, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { color: '#f3f4f6' } } } } }); | |
| drc20ChartSection.classList.remove('hidden'); | |
| } | |
| async function fetchTokenTransfers(tokenAddress, limit = 20) { | |
| // ... (implementation is the same, verify ethers.formatUnits) | |
| if (transferPollInterval) clearInterval(transferPollInterval); | |
| const loadTransfers = async () => { | |
| try { | |
| const response = await fetch(`${DOGECHAIN_EXPLORER_API_BASE}?module=account&action=tokentx&contractaddress=${tokenAddress}&page=1&offset=${limit}&sort=desc`); | |
| if (!response.ok) throw new Error(`API error: ${response.status}`); | |
| const data = await response.json(); | |
| if (data.status === "1" && Array.isArray(data.result)) { | |
| tokenTransfersTableBody.innerHTML = ''; | |
| const decimalsText = tokenMetaDecimalsSpan.textContent; | |
| if (decimalsText === 'Error' || decimalsText === 'N/A' || isNaN(parseInt(decimalsText))) { | |
| tokenTransfersTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400">Decimals not loaded.</td></tr>'; return; | |
| } | |
| const decimals = parseInt(decimalsText); | |
| data.result.forEach(tx => { | |
| const row = tokenTransfersTableBody.insertRow(); | |
| const amountFormatted = parseFloat(ethers.formatUnits(ethers.toBigInt(tx.value), decimals)).toFixed(4); | |
| row.innerHTML = `<td class="word-break-all text-xs"><a href="https://explorer.dogechain.dog/tx/${tx.hash}" target="_blank" class="text-amber-300 hover:underline">${tx.hash.substring(0,10)}...</a></td><td class="word-break-all text-xs">${tx.from.substring(0,10)}...</td><td class="word-break-all text-xs">${tx.to.substring(0,10)}...</td><td class="text-xs">${amountFormatted}</td><td class="text-xs">${new Date(parseInt(tx.timeStamp) * 1000).toLocaleString()}</td>`; | |
| }); | |
| drc20TransfersSection.classList.remove('hidden'); | |
| } else { tokenTransfersTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400">No transfers or API error.</td></tr>'; } | |
| } catch (error) { | |
| console.error("Fetch transfers error:", error); | |
| tokenTransfersTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400">Error fetching transfers.</td></tr>'; | |
| if (isLiveUpdatesEnabled) { isLiveUpdatesEnabled = false; toggleLiveUpdatesBtn.textContent = "Enable Live Updates"; toggleLiveUpdatesBtn.classList.remove('btn-danger'); if (transferPollInterval) clearInterval(transferPollInterval); showMessage("Live updates stopped due to error.", "error"); } | |
| } | |
| }; | |
| await loadTransfers(); | |
| if (isLiveUpdatesEnabled) { transferPollInterval = setInterval(loadTransfers, 30000); } | |
| } | |
| // --- Contract Interaction Module --- | |
| loadContractBtn.addEventListener('click', () => { | |
| const address = contractAddressInput.value.trim(); | |
| const abiString = contractAbiInput.value.trim(); | |
| if (!ethers.isAddress(address)) { showMessage("Invalid Contract Address.", "error"); return; } | |
| let abi; | |
| try { abi = JSON.parse(abiString); if (!Array.isArray(abi)) throw new Error("ABI must be JSON array."); } | |
| catch (error) { showMessage("Invalid ABI: " + error.message, "error"); return; } | |
| if (!provider) { showMessage("Connect wallet first (provider needed).", "error"); return; } | |
| currentContractInstance = new ethers.Contract(address, abi, signer || provider); | |
| renderContractFunctions(abi); | |
| contractFunctionsSection.classList.remove('hidden'); | |
| functionResultDisplay.classList.add('hidden'); | |
| showMessage("Contract loaded.", "success"); | |
| }); | |
| function renderContractFunctions(abi) { | |
| // ... (implementation is the same, just ensure event listener setup is correct) | |
| readFunctionsContainer.innerHTML = ''; writeFunctionsContainer.innerHTML = ''; | |
| abi.forEach(item => { | |
| if (item.type === 'function') { | |
| const isRead = item.stateMutability === 'view' || item.stateMutability === 'pure'; | |
| const container = isRead ? readFunctionsContainer : writeFunctionsContainer; | |
| const funcDiv = document.createElement('div'); funcDiv.className = 'p-3 bg-gray-700 rounded mb-3'; | |
| let inputsHtml = item.inputs.map((input, index) => `<label for="${item.name}_${index}_${isRead ? 'r' : 'w'}" class="block text-xs mt-2 mb-1">${input.name||'input_'+index} (${input.type})</label><input type="${input.type.includes('uint')||input.type.includes('int')?'number':'text'}" id="${item.name}_${index}_${isRead ? 'r' : 'w'}" class="text-sm w-full" placeholder="${input.type}">`).join(''); | |
| if (!isRead && item.payable) { inputsHtml += `<label for="${item.name}_value_${isRead ? 'r' : 'w'}" class="block text-xs mt-2 mb-1">Value (DOGE to send):</label><input type="number" id="${item.name}_value_${isRead ? 'r' : 'w'}" class="text-sm w-full" placeholder="0.0" step="any">`; } | |
| funcDiv.innerHTML = `<h5 class="font-semibold text-amber-300">${item.name}</h5>${inputsHtml}<button data-function="${item.name}" data-payable="${item.payable||false}" data-inputs='${JSON.stringify(item.inputs)}' data-readonly="${isRead}" class="btn-secondary text-sm mt-3 contract-call-btn">${isRead?'Query':'Execute'}</button>`; | |
| container.appendChild(funcDiv); | |
| } | |
| }); | |
| document.querySelectorAll('.contract-call-btn').forEach(button => button.addEventListener('click', handleContractCall)); | |
| } | |
| async function handleContractCall(event) { | |
| // ... (implementation is the same, verify ethers.parseEther and error parsing logic with Ethers v6) | |
| const button = event.target; | |
| const functionName = button.dataset.function; | |
| const isPayable = button.dataset.payable === 'true'; | |
| const isReadOnly = button.dataset.readonly === 'true'; | |
| const inputsMeta = JSON.parse(button.dataset.inputs); | |
| if (!currentContractInstance) { showMessage("Contract not loaded.", "error"); return; } | |
| if (!isReadOnly && !signer) { showMessage("Wallet Signer required for write ops.", "error"); return; } | |
| if (isReadOnly && !provider) { showMessage("Provider not available.", "error"); return; } | |
| const args = []; | |
| try { | |
| for (let i = 0; i < inputsMeta.length; i++) { | |
| const inputElement = document.getElementById(`${functionName}_${i}_${isReadOnly ? 'r' : 'w'}`); | |
| let value = inputElement.value.trim(); | |
| if (inputsMeta[i].type === 'bool') value = (value.toLowerCase()==='true'||value==='1'); | |
| else if (inputsMeta[i].type.endsWith('[]')) { try { value = JSON.parse(value); if (!Array.isArray(value)) throw new Error("Not JSON array"); } catch (e) { value = value.split(',').map(s => s.trim()).filter(s => s !== ''); } if (inputsMeta[i].type.startsWith('uint')||inputsMeta[i].type.startsWith('int')) value = value.map(v => ethers.toBigInt(v)); } | |
| else if (inputsMeta[i].type.startsWith('uint')||inputsMeta[i].type.startsWith('int')) { if (value==='') throw new Error(`Input for ${inputsMeta[i].name} required for numeric types.`); value = ethers.toBigInt(value); } | |
| else if (inputsMeta[i].type === 'address' && !ethers.isAddress(value)) throw new Error(`Invalid address for ${inputsMeta[i].name}: ${value}`); | |
| args.push(value); | |
| } | |
| } catch (error) { showMessage(`Input error for ${functionName}: ${error.message}`, "error"); return; } | |
| let txOptions = {}; | |
| if (isPayable) { const valueElement = document.getElementById(`${functionName}_value_${isReadOnly?'r':'w'}`); if (valueElement && valueElement.value) { try { txOptions.value = ethers.parseEther(valueElement.value); } catch (e) { showMessage("Invalid DOGE value.", "error"); return; } } } | |
| functionResultDisplay.classList.remove('hidden'); functionResultOutput.textContent = 'Processing...'; | |
| const originalButtonText = button.textContent; showLoadingSpinner(button, true, originalButtonText); | |
| try { | |
| let result; | |
| if (isReadOnly) { | |
| result = await currentContractInstance[functionName](...args); | |
| functionResultOutput.textContent = `Result: ${formatContractResult(result)}`; | |
| showMessage("Query successful.", "success"); | |
| } else { | |
| const contractWithSigner = currentContractInstance.connect(signer); | |
| const txResponse = await contractWithSigner[functionName](...args, txOptions); | |
| functionResultOutput.textContent = `TX Sent! Hash: ${txResponse.hash}\nWaiting for confirmation...`; | |
| showMessage(`TX ${txResponse.hash} sent.`, "info", 10000); | |
| const receipt = await txResponse.wait(); | |
| functionResultOutput.textContent = `TX Confirmed!\nHash: ${receipt.hash}\nBlock: ${receipt.blockNumber}\nStatus: ${receipt.status === 1 ? 'Success' : 'Failed'}`; | |
| showMessage(`TX ${receipt.hash} confirmed! Status: ${receipt.status === 1 ? 'Success' : 'Failed'}`, receipt.status === 1 ? "success" : "error"); | |
| if (ethers.isHexString(receipt.hash, 32)) saveTxidToHistory(receipt.hash, "N/A (Contract TX)", currentAccount); | |
| } | |
| } catch (error) { | |
| console.error(`Call ${functionName} error:`, error); | |
| let displayError = error.shortMessage || error.message || (error.reason) || (error.data ? JSON.stringify(error.data) : 'Unknown contract call error.'); | |
| if (error.revert && error.revert.args && error.revert.args.length > 0) displayError = `Reverted: ${error.revert.args.join(', ')}`; | |
| else if (error.data && typeof error.data === 'string' && error.data.startsWith('0x') && currentContractInstance) { try { const iface = currentContractInstance.interface; const decodedError = iface.parseError(error.data); if (decodedError) displayError = `Reverted: ${decodedError.name}(${decodedError.args.join(', ')})`; } catch(e) { /* ignore */ } } | |
| functionResultOutput.textContent = `Error: ${displayError}`; | |
| showMessage(`Error calling ${functionName}: ${displayError}`, "error", 10000); | |
| } finally { showLoadingSpinner(button, false, originalButtonText); } | |
| } | |
| function formatContractResult(result) { | |
| // ... (implementation is the same) | |
| if (result === null || result === undefined) return "null/undefined"; | |
| if (typeof result === 'bigint') return result.toString(); | |
| if (Array.isArray(result)) { | |
| let formattedArray = result.map(formatContractResult).join(', '); | |
| const namedProps = Object.keys(result).filter(key => isNaN(parseInt(key))); | |
| if(namedProps.length > 0 && namedProps.length === result.length) { | |
| let objString = "{ "; | |
| namedProps.forEach((key, index) => { objString += `${key}: ${formatContractResult(result[key])}`; if (index < namedProps.length -1) objString += ", "; }); | |
| objString += " }"; return objString; | |
| } | |
| return `[${formattedArray}]`; | |
| } | |
| if (typeof result === 'object' && result.constructor === Object) { try { return JSON.stringify(result, (key, value) => typeof value === 'bigint' ? value.toString() : value, 2); } catch (e) { return result.toString(); } } | |
| return result.toString(); | |
| } | |
| // --- Initialization --- | |
| renderTxidHistory(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |