feat: Add HBAR Transactions page with search, history, and detail views, including UI and API integration.

This commit is contained in:
void-57 2025-12-08 20:20:06 +05:30
parent 36d69d6181
commit a3bdd17133
3 changed files with 1919 additions and 10 deletions

449
hederaBlockchainAPI.js Normal file
View File

@ -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<Object>} - 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<Object>} - 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<Object>} - 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<Object>} - 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 = {}));

1399
index.html

File diff suppressed because it is too large Load Diff

View File

@ -615,6 +615,7 @@ footer strong {
align-items: center; align-items: center;
padding: 0.75rem 0; padding: 0.75rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
gap: 5rem;
} }
.tx-detail-row:last-child { .tx-detail-row:last-child {
@ -635,14 +636,24 @@ footer strong {
.detail-value code, .detail-value code,
code.detail-value { code.detail-value {
background: var(--bg-dark); background: var(--bg-dark);
padding: 0.25rem 0.5rem; padding: 0.5rem 0.75rem;
border-radius: 0.25rem; border-radius: 0.5rem;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 0.85rem; font-size: 0.95rem;
word-break: break-all; word-break: break-all;
white-space: normal; word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
display: block; display: block;
color: var(--text-primary); 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 { .detail-value-wrapper {
@ -1420,6 +1431,67 @@ code.detail-value {
font-size: 0.8rem; 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 */
.modal-footer { .modal-footer {
display: flex; display: flex;
@ -2776,6 +2848,7 @@ body:has(.header) .container {
align-items: flex-start; align-items: flex-start;
padding: 0.75rem 0; padding: 0.75rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
gap: 5rem;
} }
.tx-detail-row:last-child { .tx-detail-row:last-child {