feat: Add HBAR Transactions page with search, history, and detail views, including UI and API integration.
This commit is contained in:
parent
36d69d6181
commit
a3bdd17133
449
hederaBlockchainAPI.js
Normal file
449
hederaBlockchainAPI.js
Normal 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
1399
index.html
File diff suppressed because it is too large
Load Diff
81
style.css
81
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user