From a3bdd171336454e2ffba05fda15d9f9fa4fa6edc Mon Sep 17 00:00:00 2001 From: void-57 Date: Mon, 8 Dec 2025 20:20:06 +0530 Subject: [PATCH] feat: Add HBAR Transactions page with search, history, and detail views, including UI and API integration. --- hederaBlockchainAPI.js | 449 +++++++++++++ index.html | 1399 +++++++++++++++++++++++++++++++++++++++- style.css | 81 ++- 3 files changed, 1919 insertions(+), 10 deletions(-) create mode 100644 hederaBlockchainAPI.js diff --git a/hederaBlockchainAPI.js b/hederaBlockchainAPI.js new file mode 100644 index 0000000..7840484 --- /dev/null +++ b/hederaBlockchainAPI.js @@ -0,0 +1,449 @@ +(function (EXPORTS) { + "use strict"; + const hederaAPI = EXPORTS; + + // API Configuration - Mainnet Only + const NETWORK_CONFIG = { + mirrorNode: 'https://mainnet-public.mirrornode.hedera.com', + jsonRpcRelay: 'https://mainnet.hashio.io/api', + chainId: 295, // Hedera Mainnet + explorer: 'https://hashscan.io/mainnet' + }; + + /** + * Get network configuration + */ + function getNetworkConfig() { + return NETWORK_CONFIG; + } + + + /** + * Get account balance using Hedera Mirror Node API + * @param {string} address - EVM address (0x...) or Account ID (0.0.xxxx) + * @returns {Promise} - Balance information + */ + hederaAPI.getBalance = async function(address) { + try { + const config = getNetworkConfig(); + + // Clean address + address = address.trim(); + + // Determine if it's an EVM address or Account ID + let endpoint; + if (address.startsWith('0x')) { + // EVM address format + endpoint = `${config.mirrorNode}/api/v1/accounts/${address}`; + } else if (address.match(/^\d+\.\d+\.\d+$/)) { + // Account ID format (0.0.xxxx) + endpoint = `${config.mirrorNode}/api/v1/accounts/${address}`; + } else { + throw new Error('Invalid address format. Use EVM address (0x...) or Account ID (0.0.xxxx)'); + } + + const response = await fetch(endpoint); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Account not found. Make sure the account exists on the network.'); + } + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Convert balance from tinybars to HBAR (1 HBAR = 100,000,000 tinybars) + const balanceInTinybars = parseInt(data.balance.balance); + const balanceInHbar = balanceInTinybars / 100000000; + + return { + address: address, + accountId: data.account, + evmAddress: data.evm_address, + balance: balanceInHbar, + balanceTinybars: balanceInTinybars, + autoRenewPeriod: data.auto_renew_period, + expiryTimestamp: data.expiry_timestamp, + memo: data.memo, + key: data.key + }; + } catch (error) { + console.error('Error fetching balance:', error); + throw error; + } + }; + + /** + * Get transaction history using Hedera Mirror Node API + * @param {string} address - EVM address or Account ID + * @param {Object} options - Query options (limit, order, timestamp) + * @returns {Promise} - Transaction history + */ + hederaAPI.getTransactionHistory = async function(address, options = {}) { + try { + const config = getNetworkConfig(); + address = address.trim(); + + // Build query parameters for account endpoint + const params = new URLSearchParams(); + params.append('limit', options.limit || 25); + params.append('order', options.order || 'desc'); + + if (options.timestamp) { + params.append('timestamp', options.timestamp); + } + + // Use the account endpoint which includes transactions + let endpoint; + if (address.startsWith('0x')) { + endpoint = `${config.mirrorNode}/api/v1/accounts/${address}?${params}`; + } else if (address.match(/^\d+\.\d+\.\d+$/)) { + endpoint = `${config.mirrorNode}/api/v1/accounts/${address}?${params}`; + } else { + throw new Error('Invalid address format'); + } + + const response = await fetch(endpoint); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Account not found'); + } + throw new Error(`API Error: ${response.status}`); + } + + const data = await response.json(); + + // Check if transactions exist in response + if (!data.transactions || data.transactions.length === 0) { + return { + transactions: [], + links: data.links || {} + }; + } + + // Process transactions + const transactions = data.transactions.map(tx => { + // Determine transaction type and amount + let type = 'unknown'; + let amount = 0; + let counterparty = null; + + if (tx.transfers && tx.transfers.length > 0) { + // Find transfers involving our address + const accountId = data.account; // Use the account ID from response + const ourTransfers = tx.transfers.filter(t => + t.account === address || + t.account === accountId + ); + + if (ourTransfers.length > 0) { + const transfer = ourTransfers[0]; + amount = Math.abs(transfer.amount) / 100000000; // Convert to HBAR + + if (transfer.amount > 0) { + type = 'receive'; + // Find sender + const senderTransfer = tx.transfers.find(t => t.amount < 0); + if (senderTransfer) counterparty = senderTransfer.account; + } else { + type = 'send'; + // Find receiver + const receiverTransfer = tx.transfers.find(t => t.amount > 0 && t.account !== address && t.account !== accountId); + if (receiverTransfer) counterparty = receiverTransfer.account; + } + } + } + + // Convert Base64 transaction hash to hex format + let hexHash = tx.transaction_hash; + if (hexHash && !hexHash.startsWith('0x')) { + try { + const binaryString = atob(hexHash); + hexHash = '0x' + Array.from(binaryString) + .map(char => char.charCodeAt(0).toString(16).padStart(2, '0')) + .join(''); + } catch (e) { + console.warn('Could not convert hash to hex:', e); + } + } + + return { + id: tx.transaction_id, + hash: hexHash, // Add transaction hash in hex format + consensusTimestamp: tx.consensus_timestamp, + type: type, + amount: amount, + counterparty: counterparty, + result: tx.result, + name: tx.name, + memo: tx.memo_base64 ? atob(tx.memo_base64) : '', + charged_tx_fee: tx.charged_tx_fee / 100000000, // Convert to HBAR + max_fee: tx.max_fee ? tx.max_fee / 100000000 : 0, + valid_start_timestamp: tx.valid_start_timestamp, + node: tx.node, + scheduled: tx.scheduled, + nonce: tx.nonce, + transfers: tx.transfers + }; + }); + + return { + transactions: transactions, + links: data.links || {} + }; + } catch (error) { + console.error('Error fetching transaction history:', error); + throw error; + } + }; + + /** + * Get transaction details by transaction ID + * @param {string} transactionId - Transaction ID + * @returns {Promise} - Transaction details + */ + hederaAPI.getTransactionById = async function(transactionId) { + try { + const config = getNetworkConfig(); + const endpoint = `${config.mirrorNode}/api/v1/transactions/${transactionId}`; + + const response = await fetch(endpoint); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Transaction not found'); + } + throw new Error(`API Error: ${response.status}`); + } + + const data = await response.json(); + + if (data.transactions && data.transactions.length > 0) { + const tx = data.transactions[0]; + + // Convert Base64 transaction hash to hex format + let hexHash = tx.transaction_hash; + if (hexHash && !hexHash.startsWith('0x')) { + try { + // Decode Base64 to binary, then convert to hex + const binaryString = atob(hexHash); + hexHash = '0x' + Array.from(binaryString) + .map(char => char.charCodeAt(0).toString(16).padStart(2, '0')) + .join(''); + } catch (e) { + console.warn('Could not convert hash to hex:', e); + } + } + + + // Fetch block number based on consensus timestamp + let blockNumber = null; + try { + const blockEndpoint = `${config.mirrorNode}/api/v1/blocks?timestamp=gte:${tx.consensus_timestamp}&limit=1&order=asc`; + console.log('Fetching block from:', blockEndpoint); + const blockResponse = await fetch(blockEndpoint); + console.log('Block response status:', blockResponse.status); + if (blockResponse.ok) { + const blockData = await blockResponse.json(); + console.log('Block data:', blockData); + if (blockData.blocks && blockData.blocks.length > 0) { + blockNumber = blockData.blocks[0].number; + console.log('Block number found:', blockNumber); + } else { + console.warn('No blocks found in response'); + } + } else { + console.warn('Block fetch failed with status:', blockResponse.status); + } + } catch (e) { + console.warn('Could not fetch block number:', e); + } + + return { + id: tx.transaction_id, + hash: hexHash, // Transaction hash in hex format + consensusTimestamp: tx.consensus_timestamp, + result: tx.result, + name: tx.name, + memo: memo, + charged_tx_fee: tx.charged_tx_fee / 100000000, + max_fee: tx.max_fee ? tx.max_fee / 100000000 : 0, + valid_start_timestamp: tx.valid_start_timestamp, + node: tx.node, + transfers: tx.transfers, + block_number: blockNumber, + raw: tx + }; + } + + throw new Error('Transaction not found'); + } catch (error) { + console.error('Error fetching transaction:', error); + throw error; + } + }; + + /** + * Send HBAR using JSON-RPC Relay (EVM-compatible) + * @param {string} fromPrivateKey - Sender's private key (hex format) + * @param {string} toAddress - Recipient's EVM address + * @param {number} amount - Amount in HBAR + * @param {string} memo - Optional memo + * @returns {Promise} - Transaction result + */ + hederaAPI.sendHBAR = async function(fromPrivateKey, toAddress, amount, memo = '') { + try { + const config = getNetworkConfig(); + + // Validate inputs + if (!fromPrivateKey || fromPrivateKey.length !== 64) { + throw new Error('Invalid private key format. Expected 64-character hex string.'); + } + + if (!toAddress || !toAddress.startsWith('0x')) { + throw new Error('Invalid recipient address. Expected EVM address (0x...)'); + } + + if (amount <= 0) { + throw new Error('Amount must be greater than 0'); + } + + // Use Web3.js to create and sign the transaction + if (typeof Web3 === 'undefined') { + throw new Error('Web3.js is required for sending transactions'); + } + + const web3 = new Web3(config.jsonRpcRelay); + + // Add 0x prefix to private key if not present + const privateKey = fromPrivateKey.startsWith('0x') ? fromPrivateKey : '0x' + fromPrivateKey; + + // Create account from private key + const account = web3.eth.accounts.privateKeyToAccount(privateKey); + const fromAddress = account.address; + + // Get current gas price + const gasPrice = await web3.eth.getGasPrice(); + + // Get nonce + const nonce = await web3.eth.getTransactionCount(fromAddress, 'pending'); + + // Convert HBAR to Wei (1 HBAR = 10^18 Wei in EVM context) + const amountInWei = web3.utils.toWei(amount.toString(), 'ether'); + + // Prepare transaction object for gas estimation + let gasLimit = 21000; // Default for existing accounts + + // Try to estimate gas (will be higher for new accounts) + try { + const estimatedGas = await web3.eth.estimateGas({ + from: fromAddress, + to: toAddress, + value: amountInWei + }); + gasLimit = Math.floor(estimatedGas * 1.2); // Add 20% buffer + console.log('Estimated gas:', estimatedGas, 'Using:', gasLimit); + } catch (error) { + // If estimation fails, use high limit for new account creation + console.warn('Gas estimation failed, using high limit for potential new account:', error.message); + gasLimit = 800000; // High limit for new account auto-creation + } + + // Prepare transaction + const tx = { + from: fromAddress, + to: toAddress, + value: amountInWei, + gas: gasLimit, + gasPrice: gasPrice, + nonce: nonce, + chainId: config.chainId + }; + + + // Sign transaction + const signedTx = await account.signTransaction(tx); + + // Send transaction + const receipt = await web3.eth.sendSignedTransaction(signedTx.rawTransaction); + + return { + success: true, + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber, + from: receipt.from, + to: receipt.to, + gasUsed: receipt.gasUsed, + status: receipt.status, + explorerUrl: `${config.explorer}/transaction/${receipt.transactionHash}` + }; + } catch (error) { + console.error('Error sending HBAR:', error); + + // Parse error message + let errorMessage = error.message; + if (error.message.includes('insufficient funds')) { + errorMessage = 'Insufficient balance to complete this transaction'; + } else if (error.message.includes('nonce')) { + errorMessage = 'Transaction nonce error. Please try again.'; + } else if (error.message.includes('gas')) { + errorMessage = 'Gas estimation failed. Please check the transaction details.'; + } + + throw new Error(errorMessage); + } + }; + + + /** + * Validate address format + * @param {string} address - Address to validate + * @returns {Object} - Validation result + */ + hederaAPI.validateAddress = function(address) { + address = address.trim(); + + // Check EVM address format + if (address.startsWith('0x')) { + const isValid = /^0x[a-fA-F0-9]{40}$/.test(address); + return { + valid: isValid, + type: 'evm', + address: address + }; + } + + // Check Account ID format (0.0.xxxx) + if (address.match(/^\d+\.\d+\.\d+$/)) { + return { + valid: true, + type: 'accountId', + address: address + }; + } + + return { + valid: false, + type: 'unknown', + address: address + }; + }; + + /** + * Format timestamp to readable date + * @param {string} timestamp - Consensus timestamp + * @returns {string} - Formatted date + */ + hederaAPI.formatTimestamp = function(timestamp) { + if (!timestamp) return 'N/A'; + + // Timestamp format: seconds.nanoseconds + const [seconds, nanoseconds] = timestamp.split('.'); + const date = new Date(parseInt(seconds) * 1000); + + return date.toLocaleString(); + }; + +})(typeof module === "object" ? module.exports : (window.hederaAPI = {})); diff --git a/index.html b/index.html index 02035eb..3b10a50 100644 --- a/index.html +++ b/index.html @@ -48,15 +48,15 @@
  • - + - Transactions (Coming Soon) + Transactions
  • - + - Send (Coming Soon) + Send
  • @@ -319,6 +319,280 @@ + + + + + + @@ -326,6 +600,7 @@ + @@ -560,11 +1826,11 @@ Generate - - @@ -573,5 +1839,126 @@ Recover + + + + + + + + + diff --git a/style.css b/style.css index 6735552..dbafdf4 100644 --- a/style.css +++ b/style.css @@ -615,6 +615,7 @@ footer strong { align-items: center; padding: 0.75rem 0; border-bottom: 1px solid var(--border); + gap: 5rem; } .tx-detail-row:last-child { @@ -635,14 +636,24 @@ footer strong { .detail-value code, code.detail-value { background: var(--bg-dark); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; font-family: 'Courier New', monospace; - font-size: 0.85rem; + font-size: 0.95rem; word-break: break-all; - white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; display: block; color: var(--text-primary); + line-height: 1.6; + letter-spacing: 0.02em; +} + +/* Transaction hash specific styling */ +.tx-detail-row code.detail-value, +.tx-detail-row .detail-value code { + color: var(--primary-light); } .detail-value-wrapper { @@ -1420,6 +1431,67 @@ code.detail-value { font-size: 0.8rem; } +/* Error Modal Styles */ +.modal-header.error { + flex-direction: column; + text-align: center; + padding: 2rem 1.5rem 1rem; + border-bottom: none; +} + +.error-icon { + font-size: 3rem; + color: var(--error); + margin-bottom: 0.5rem; + animation: scaleIn 0.5s ease; +} + +.error-details { + background: var(--bg-dark); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.error-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.error-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.error-label { + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 600; +} + +.error-value { + color: var(--text-primary); + font-weight: 500; + font-size: 0.95rem; + line-height: 1.5; +} + +.error-details-text { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem; + margin: 0; + color: var(--text-primary); + font-family: 'Courier New', monospace; + font-size: 0.85rem; + line-height: 1.8; + white-space: pre-wrap; + word-wrap: break-word; +} + /* Modal Footer */ .modal-footer { display: flex; @@ -2776,6 +2848,7 @@ body:has(.header) .container { align-items: flex-start; padding: 0.75rem 0; border-bottom: 1px solid var(--border); + gap: 5rem; } .tx-detail-row:last-child {