feat: Add comprehensive wallet features

- Add balance checking for ETH, USDC, and USDT tokens
- Implement transaction history with filtering (All/Received/Sent)
- Add pagination support for transaction history
- Implement transaction hash search functionality
- Fix ETH transaction sending issues
- Add gas fee calculation popup before sending transactions

This update significantly enhances the wallet functionality by providing
users with complete visibility into their token balances and transaction
history, along with improved transaction management and gas fee transparency.
This commit is contained in:
void-57 2025-12-29 00:23:03 +05:30
parent bae54354fc
commit 8d0f9db29b
4 changed files with 1228 additions and 178 deletions

View File

@ -895,6 +895,7 @@ main {
margin: 0 auto;
}
aside {
view-transition-name: search-history;
padding-bottom: 1.5rem;
@ -958,6 +959,101 @@ aside h4 {
padding-bottom: 0.5rem;
}
/* Transaction list styling matching BTC wallet */
.transaction {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
padding: 1rem;
border-bottom: solid thin rgba(var(--text-color), 0.1);
}
.transaction:last-child {
border-bottom: none;
}
.transaction__icon {
display: flex;
align-items: flex-start;
padding-top: 0.2rem;
}
.transaction__icon .icon {
width: 1.5rem;
height: 1.5rem;
fill: rgba(var(--text-color), 0.7);
}
.transaction.in .icon {
fill: var(--green);
}
.transaction.out .icon.sent {
fill: var(--danger-color);
}
.transaction__time {
font-size: 0.85rem;
color: rgba(var(--text-color), 0.7);
}
.transaction__amount {
font-weight: 600;
font-size: 0.95rem;
}
.transaction.in .transaction__amount {
color: var(--green);
}
.transaction.out .transaction__amount {
color: var(--danger-color);
}
.transaction__receiver {
font-size: 0.9rem;
color: rgba(var(--text-color), 0.9);
word-break: break-all;
}
.tx-participant {
color: var(--accent-color);
text-decoration: none;
font-family: monospace;
font-size: 0.85rem;
}
.tx-participant:hover {
text-decoration: underline;
}
.wrap-around {
word-break: break-all;
}
.transaction__id {
display: inline-flex;
align-items: center;
}
.transaction__id .icon {
width: 1rem;
height: 1rem;
fill: currentColor;
}
#transaction_list {
max-height: 500px;
overflow-y: auto;
/* Hide scrollbar for Chrome, Safari and Opera */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
#transaction_list::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
#error_section {
display: grid;
height: 100%;
@ -1179,3 +1275,17 @@ aside h4 {
animation: none !important;
}
}
.transaction__time {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.transaction__amount {
font-size: 0.75rem;
margin-left: auto;
white-space: nowrap;
flex-shrink: 0;
}

View File

@ -1113,6 +1113,101 @@ aside {
}
}
/* Transaction list styling matching BTC wallet */
.transaction {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
padding: 1rem;
border-bottom: solid thin rgba(var(--text-color), 0.1);
&:last-child {
border-bottom: none;
}
}
.transaction__icon {
display: flex;
align-items: flex-start;
padding-top: 0.2rem;
.icon {
width: 1.5rem;
height: 1.5rem;
fill: rgba(var(--text-color), 0.7);
}
}
.transaction.in .icon {
fill: var(--green);
}
.transaction.out .icon.sent {
fill: var(--danger-color);
}
.transaction__time {
font-size: 0.85rem;
color: rgba(var(--text-color), 0.7);
}
.transaction__amount {
font-weight: 600;
font-size: 0.95rem;
}
.transaction.in .transaction__amount {
color: var(--green);
}
.transaction.out .transaction__amount {
color: var(--danger-color);
}
.transaction__receiver {
font-size: 0.9rem;
color: rgba(var(--text-color), 0.9);
word-break: break-all;
}
.tx-participant {
color: var(--accent-color);
text-decoration: none;
font-family: monospace;
font-size: 0.85rem;
&:hover {
text-decoration: underline;
}
}
.wrap-around {
word-break: break-all;
}
.transaction__id {
display: inline-flex;
align-items: center;
.icon {
width: 1rem;
height: 1rem;
fill: currentColor;
}
}
#transaction_list {
max-height: 500px;
overflow-y: auto;
/* Hide scrollbar for Chrome, Safari and Opera */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
}
@media only screen and (max-width: 640px) {
.hide-on-small {
display: none;
@ -1172,7 +1267,9 @@ aside {
}
}
aside {
min-width: 18rem;
border-right: solid thin rgba(var(--text-color), 0.3);
overflow-y: auto;
@ -1238,4 +1335,37 @@ aside {
::view-transition-new(*) {
animation: none !important;
}
}
}
// Transaction display styles
.transaction__time {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.transaction__amount {
font-size: 0.75rem;
margin-left: auto;
white-space: nowrap;
flex-shrink: 0;
}
.transaction {
grid-template-columns: auto 1fr;
gap: 0.5rem;
}
.transaction__icon {
display: flex;
align-items: flex-start;
padding-top: 0.2rem;
.icon {
width: 1rem;
height: 1rem;
}
}

View File

@ -11,6 +11,7 @@
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap"
rel="stylesheet">
</head>
<body class="hidden">
@ -170,7 +171,7 @@
<div id="transaction_result_popup__content" class="grid gap-2"></div>
</sm-popup>
<script>
/* Constants for FLO blockchain operations !!Make sure to add this at beginning!! */
/* FLO blockchain configuration - These constants must be defined before loading FLO scripts */
const floGlobals = {
blockchain: "FLO",
tokenURL: 'https://ranchimallflo.ranchimall.net/',
@ -195,7 +196,7 @@
const uiGlobals = {}
const { html, svg, render: renderElem } = uhtml;
uiGlobals.connectionErrorNotification = []
//Checks for internet connection status
// Check for internet connection status and show notification if offline
if (!navigator.onLine)
uiGlobals.connectionErrorNotification.push(notify('There seems to be a problem connecting to the internet, Please check you internet connection.', 'error'))
window.addEventListener('offline', () => {
@ -207,12 +208,12 @@
})
notify('We are back online.', 'success')
})
// Use instead of document.getElementById
// Helper function to get element by ID (shorthand for document.getElementById)
function getRef(elementId) {
return document.getElementById(elementId)
}
let zIndex = 50
// function required for popups or modals to appear
// Opens a popup/modal and manages the popup stack for proper layering
function openPopup(popupId, pinned) {
if (popupStack.peek() === undefined) {
document.addEventListener('keydown', (e) => {
@ -243,7 +244,7 @@
switch (e.target.id) {
}
})
//Function for displaying toast notifications. pass in error for mode param if you want to show an error.
// Display toast notifications. Pass 'error' or 'success' as mode parameter
function notify(message, mode, options = {}) {
let icon
switch (mode) {
@ -258,7 +259,20 @@
if (mode === 'error') {
console.error(message)
}
return getRef("notification_drawer").push(message, { icon, ...options });
// Ensure notification drawer element exists and is fully initialized
const notificationDrawer = getRef("notification_drawer");
if (!notificationDrawer || typeof notificationDrawer.push !== 'function') {
console.warn('Notification drawer not ready, logging message:', message);
// Queue the notification to show later when drawer is ready
if (!window._pendingNotifications) {
window._pendingNotifications = [];
}
window._pendingNotifications.push({ message, mode, options, icon });
return null;
}
return notificationDrawer.push(message, { icon, ...options });
}
// displays a popup for asking permission. Use this instead of JS confirm
/**
@ -399,7 +413,7 @@
this.routingStart(this.state)
}
if (this.routes[page]) {
//Actual routing step
// Execute the route handler for the current page
await this.routes[page](this.state)
this.lastPage = page
} else {
@ -549,27 +563,27 @@
}
// put this once near the top with your other globals
// Initialize IndexedDB for storing contact addresses
let idbReady;
window.addEventListener('load', () => {
// 1) Initialize IndexedDB BEFORE any routing/reads
// Initialize the database before any routing or data reads
idbReady = compactIDB.initDB('floEthereum', { contacts: {} })
.then((res) => { console.log(res); })
.catch((err) => { console.error(err); });
// 2) After DB is ready, wire listeners and route once
// Set up event listeners and routing after database is ready
idbReady.then(() => {
const routeNow = () => router.routeTo(location.hash);
// Utility/UI listeners
// Set up UI event listeners for copy notifications and ripple effects
document.addEventListener('copy', () => notify('copied', 'success'));
document.addEventListener('pointerdown', (e) => {
const target = e.target.closest('button:not(:disabled), .interactive:not(:disabled)');
if (target) createRipple(e, target);
});
// Ethereum / MetaMask
// Handle Ethereum provider and MetaMask connection
if (window.ethereum) {
window.ethereum.on('chainChanged', (chainId) => {
window.currentChainId = chainId;
@ -594,7 +608,7 @@
routeNow();
});
// Account status hooks (kept exactly like your current code)
// Listen for MetaMask account changes
ethereum.on('accountsChanged', (accounts) => {
getRef('eth_balance_wrapper').classList.add('hidden');
setMetaMaskStatus(accounts.length > 0);
@ -606,7 +620,7 @@
setMetaMaskStatus(false);
});
} else {
// no ethereum provider—just route
// No MetaMask detected, proceed with normal routing
routeNow();
}
@ -615,6 +629,19 @@
});
});
// Process pending notifications after a delay to ensure custom elements are ready
setTimeout(() => {
if (window._pendingNotifications && window._pendingNotifications.length > 0) {
const notificationDrawer = getRef("notification_drawer");
if (notificationDrawer && typeof notificationDrawer.push === 'function') {
window._pendingNotifications.forEach(({ message, icon, options }) => {
notificationDrawer.push(message, { icon, ...options });
});
window._pendingNotifications = [];
}
}
}, 1000); // Give custom elements time to fully initialize
router.addRoute('404', () => {
renderElem(getRef('page_container'), html`
@ -638,8 +665,8 @@
</h2>
<sm-form oninvalid="handleInvalidSearch()">
<div id="input_wrapper">
<sm-input id="check_balance_input" class="password-field flex-1" placeholder="FLO/BTC private key or Eth address"
type="password" animate required>
<sm-input id="check_balance_input" class="password-field flex-1" placeholder="Address, private key, or tx hash"
type="password" animate>
<svg class="icon" slot="icon" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"> <g> <rect fill="none" height="24" width="24"></rect> </g> <g> <path d="M21,10h-8.35C11.83,7.67,9.61,6,7,6c-3.31,0-6,2.69-6,6s2.69,6,6,6c2.61,0,4.83-1.67,5.65-4H13l2,2l2-2l2,2l4-4.04L21,10z M7,15c-1.65,0-3-1.35-3-3c0-1.65,1.35-3,3-3s3,1.35,3,3C10,13.65,8.65,15,7,15z"> </path> </g> </svg>
<label slot="right" class="interact">
<input type="checkbox" class="hidden" autocomplete="off" readonly
@ -649,8 +676,8 @@
</label>
</sm-input>
<div class="multi-state-button">
<button id="check_balance_button" class="button button--primary h-100 w-100" type="submit" onclick=${() => checkBalance()} disabled>
Check balance
<button id="check_balance_button" class="button button--primary h-100 w-100" type="submit" onclick=${() => checkBalance()}>
Search
</button>
</div>
</div>
@ -662,6 +689,12 @@
renderError('Please switch MetaMask to Ethereum Mainnet')
}
renderSearchedAddressList()
// Handle URL parameters after page is rendered
// Use setTimeout to ensure DOM is fully ready
setTimeout(() => {
handleUrlParams();
}, 100);
}
function renderError(title, description) {
if (!title)
@ -727,88 +760,465 @@
console.error(error)
})
}
// Track current page and address for transaction pagination
let currentPage = 1;
let currentAddress = '';
let currentFloAddress = '';
let allTransactions = [];
const TRANSACTIONS_PER_PAGE = 10;
function checkBalance(ethAddress, floAddress) {
if (!ethAddress) {
let keyToConvert = document.querySelector('#check_balance_input').value.trim()
// Check if input is empty
if (!keyToConvert) {
notify('Please enter an Ethereum address, private key, or transaction hash', 'error');
return;
}
// Check if it's a valid Ethereum address
if (ethOperator.isValidAddress(keyToConvert)) {
ethAddress = keyToConvert
} else {
if (/^[0-9a-fA-F]{64}$/.test(keyToConvert)) {
keyToConvert = coinjs.privkey2wif(keyToConvert)
}
// Check if it's a transaction hash (0x followed by 64 hex characters)
else if (/^0x[0-9a-fA-F]{64}$/.test(keyToConvert)) {
viewTransactionDetails(keyToConvert);
return;
}
// Otherwise, try to convert as private key
else {
try {
if (/^[0-9a-fA-F]{64}$/.test(keyToConvert)) {
keyToConvert = coinjs.privkey2wif(keyToConvert)
}
const ethPrivateKey = coinjs.wif2privkey(keyToConvert).privkey;
ethAddress = floEthereum.ethAddressFromPrivateKey(ethPrivateKey)
floAddress = floCrypto.getFloID(keyToConvert)
} catch (error) {
notify('Invalid input. Please enter a valid Ethereum address or private key.', 'error');
return;
}
const ethPrivateKey = coinjs.wif2privkey(keyToConvert).privkey;
ethAddress = floEthereum.ethAddressFromPrivateKey(ethPrivateKey)
floAddress = floCrypto.getFloID(keyToConvert)
}
}
if (!ethAddress) return
buttonLoader('check_balance_button', true)
Promise.all([
ethOperator.getBalance(ethAddress),
ethOperator.getTokenBalance(ethAddress, 'usdc'),
ethOperator.getTokenBalance(ethAddress, 'usdt')
])
.then(([etherBalance, usdcBalance, usdtBalance]) => {
compactIDB.readData('contacts', floAddress || ethAddress).then(result => {
if (result) return
compactIDB.addData('contacts', {
ethAddress,
}, floAddress || ethAddress).then(() => {
renderSearchedAddressList()
}).catch((error) => {
console.error(error)
})
})
renderElem(getRef('eth_balance_wrapper'), html`
<div class="grid">
<div class="label">ETH address</div>
<sm-copy id="eth_address" value=${ethAddress}></sm-copy>
</div>
${floAddress && floAddress !== ethAddress ? html`
<div class="grid">
<div class="label">FLO address</div>
<sm-copy id="flo_address" value=${floAddress}></sm-copy>
</div>
`: ''}
<div class="grid gap-1">
<h4>Balance</h4>
<ul id="eth_address_balance" class="flex flex-direction-column gap-0-5">
<li class="flex align-center space-between">
<p>Ether</p>
<b id="ether_balance">${etherBalance} ETH</b>
</li>
<li class="flex align-center space-between">
<p>USDC</p>
<b id="usdc_balance">${usdcBalance} USDC</b>
</li>
<li class="flex align-center space-between">
<p>USDT</p>
<b id="usdt_balance">${usdtBalance} USDT</b>
</li>
</ul>
</div>
`)
getRef('eth_balance_wrapper').classList.remove('hidden')
getRef('eth_balance_wrapper').animate([
{
transform: 'translateY(-1rem)',
opacity: 0
},
{
transform: 'none',
opacity: 1
}
], {
easing: 'ease',
duration: 300,
fill: 'forwards'
})
}).catch((error) => {
notify(error, 'error')
}).finally(() => {
buttonLoader('check_balance_button', false)
})
// Reset pagination when checking new address
currentPage = 1;
currentAddress = ethAddress;
currentFloAddress = floAddress;
loadTransactionsPage(ethAddress, floAddress, currentPage);
}
async function loadTransactionsPage(ethAddress, floAddress, page) {
buttonLoader('check_balance_button', true);
try {
const results = await Promise.allSettled([
ethOperator.getBalance(ethAddress),
ethOperator.getTokenBalance(ethAddress, 'usdc'),
ethOperator.getTokenBalance(ethAddress, 'usdt'),
ethOperator.getTransactionHistory(ethAddress, {
page: page,
offset: TRANSACTIONS_PER_PAGE,
sort: 'desc'
})
]);
// Extract balance and transaction data, using defaults if any request failed
const etherBalance = results[0].status === 'fulfilled' ? results[0].value : '0';
const usdcBalance = results[1].status === 'fulfilled' ? results[1].value : '0';
const usdtBalance = results[2].status === 'fulfilled' ? results[2].value : '0';
const transactions = results[3].status === 'fulfilled' ? results[3].value : [];
// Store transactions for filtering
allTransactions = transactions;
// Log warnings if any API requests failed
if (results[0].status === 'rejected') {
console.warn('Failed to fetch ETH balance:', results[0].reason);
}
if (results[1].status === 'rejected') {
console.warn('Failed to fetch USDC balance:', results[1].reason);
}
if (results[2].status === 'rejected') {
console.warn('Failed to fetch USDT balance:', results[2].reason);
}
if (results[3].status === 'rejected') {
console.warn('Failed to fetch transaction history:', results[3].reason);
}
compactIDB.readData('contacts', floAddress || ethAddress).then(result => {
if (result) return
compactIDB.addData('contacts', {
ethAddress,
}, floAddress || ethAddress).then(() => {
renderSearchedAddressList()
}).catch((error) => {
console.error(error)
})
})
renderBalanceAndTransactions(ethAddress, floAddress, etherBalance, usdcBalance, usdtBalance, transactions, page);
} catch (error) {
notify(error.message || error, 'error');
} finally {
buttonLoader('check_balance_button', false);
}
}
function renderBalanceAndTransactions(ethAddress, floAddress, etherBalance, usdcBalance, usdtBalance, transactions, page) {
// Update URL to reflect the current address being viewed
const url = new URL(window.location);
url.searchParams.set('address', ethAddress);
url.searchParams.delete('tx');
url.searchParams.delete('page');
// Update browser URL without reloading the page
window.history.pushState({}, '', url.pathname + url.search + url.hash);
// Determine if pagination buttons should be enabled
const hasNextPage = transactions.length >= TRANSACTIONS_PER_PAGE;
const hasPrevPage = page > 1;
renderElem(getRef('eth_balance_wrapper'), html`
<div class="grid">
<div class="label">ETH address</div>
<sm-copy id="eth_address" value=${ethAddress}></sm-copy>
</div>
${floAddress && floAddress !== ethAddress ? html`
<div class="grid">
<div class="label">FLO address</div>
<sm-copy id="flo_address" value=${floAddress}></sm-copy>
</div>
`: ''}
<div class="grid gap-1">
<h4>Balance</h4>
<ul id="eth_address_balance" class="flex flex-direction-column gap-0-5">
<li class="flex align-center space-between">
<p>Ether</p>
<b id="ether_balance">${etherBalance} ETH</b>
</li>
<li class="flex align-center space-between">
<p>USDC</p>
<b id="usdc_balance">${usdcBalance} USDC</b>
</li>
<li class="flex align-center space-between">
<p>USDT</p>
<b id="usdt_balance">${usdtBalance} USDT</b>
</li>
</ul>
</div>
<div class="grid gap-1 margin-top-1">
<div class="flex align-center space-between">
<h4>Transactions</h4>
<sm-chips id="tx_filter_chips" onchange=${(e) => {
const selectedValue = e.detail?.value || e.target.value || 'all';
filterTransactions(selectedValue, transactions, ethAddress);
}}>
<sm-chip value="all" selected>All</sm-chip>
<sm-chip value="sent">Sent</sm-chip>
<sm-chip value="received">Received</sm-chip>
</sm-chips>
</div>
<ul id="transaction_list" class="grid gap-0-5">
${renderTransactionList(transactions, ethAddress, 'all')}
</ul>
<div class="flex align-center space-between gap-1 margin-top-1">
<button
class="button button--small"
onclick=${() => loadPreviousPage()}
?disabled=${!hasPrevPage}
>
Previous
</button>
<span class="color-0-7">Page ${page}</span>
<button
class="button button--small"
onclick=${() => loadNextPage()}
?disabled=${!hasNextPage}
>
Next
</button>
</div>
</div>
`);
getRef('eth_balance_wrapper').classList.remove('hidden');
getRef('eth_balance_wrapper').animate([
{
transform: 'translateY(-1rem)',
opacity: 0
},
{
transform: 'none',
opacity: 1
}
], {
easing: 'ease',
duration: 300,
fill: 'forwards'
});
}
function loadNextPage() {
currentPage++;
loadTransactionsPage(currentAddress, currentFloAddress, currentPage);
}
function loadPreviousPage() {
if (currentPage > 1) {
currentPage--;
loadTransactionsPage(currentAddress, currentFloAddress, currentPage);
}
}
function renderTransactionList(transactions, userAddress, filter = 'all') {
if (!transactions || transactions.length === 0) {
return html`<li class="text-center color-0-8"><p>No transactions found</p></li>`;
}
let filteredTxs = transactions;
if (filter === 'sent') {
filteredTxs = transactions.filter(tx => tx.isSent);
} else if (filter === 'received') {
filteredTxs = transactions.filter(tx => tx.isReceived);
}
if (filteredTxs.length === 0) {
return html`<li class="text-center color-0-8"><p>No ${filter} transactions found</p></li>`;
}
return filteredTxs.map(tx => {
const date = new Date(tx.timestamp * 1000);
const formattedDate = date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const isReceived = tx.isReceived;
const type = isReceived ? 'in' : 'out';
const amountClass = isReceived ? 'tx-received' : 'tx-sent';
const amountPrefix = isReceived ? '+' : '-';
const displayAddress = isReceived ? tx.from : tx.to;
const directionText = isReceived ? 'Received from' : 'Sent to';
// Arrow icons matching BTC wallet
const icon = isReceived
? svg`<svg class="icon" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M20 12l-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z"/></svg>`
: svg`<svg class="icon sent" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/></svg>`;
const className = `transaction grid ${type}`;
return html`
<li class=${className}>
<div class="transaction__icon">${icon}</div>
<div class="grid gap-0-5">
<div class="flex gap-1">
<time class="transaction__time">${formattedDate}</time>
<div class="transaction__amount">${amountPrefix}${tx.value.toFixed(8)} ${tx.symbol}</div>
</div>
<div class="transaction__receiver">
${directionText} <a href="#" class="tx-participant wrap-around">${displayAddress}</a>
</div>
<div class="flex gap-0-5 flex-wrap align-center">
<button class="button button--small gap-0-3 align-center button--colored transaction__id" onclick=${() => viewTransactionDetails(tx.hash)}>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
View details
</button>
</div>
</div>
</li>
`;
});
}
function filterTransactions(filter, transactions, userAddress) {
const txList = getRef('transaction_list');
if (txList) {
const renderedList = renderTransactionList(transactions, userAddress, filter);
renderElem(txList, html`${renderedList}`);
}
}
async function viewTransactionDetails(txHash, preserveAddress = false) {
try {
buttonLoader('check_balance_button', true);
// Update URL to show the transaction hash
const url = new URL(window.location);
url.searchParams.set('tx', txHash);
url.searchParams.delete('page');
// Remove address from URL unless viewing from transaction history
if (!preserveAddress) {
url.searchParams.delete('address');
}
// Update browser URL without reloading the page
window.history.pushState({}, '', url.pathname + url.search + url.hash);
const txDetails = await ethOperator.getTransactionDetails(txHash);
let formattedDate = 'Pending';
if (txDetails.timestamp) {
const date = new Date(txDetails.timestamp * 1000);
formattedDate = date.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
const statusClass = txDetails.status === 'success' ? 'tx-success' : txDetails.status === 'pending' ? 'color-0-7' : 'tx-failed';
const statusText = txDetails.status === 'success' ? 'Success' : txDetails.status === 'pending' ? 'Pending' : 'Failed';
// Show transaction details
renderElem(getRef('eth_balance_wrapper'), html`
<div class="grid gap-1">
<div class="flex align-center space-between">
<h3>Transaction Details</h3>
<button class="button button--small" onclick=${() => {
const url = new URL(window.location);
url.searchParams.delete('tx');
window.history.pushState({}, '', url.pathname + url.search + url.hash);
// Go back to address view
handleUrlParams();
}}>
Back
</button>
</div>
<div class="grid gap-0-5">
<div class="label">Status</div>
<strong class=${statusClass}>${statusText}</strong>
</div>
<div class="grid gap-0-5">
<div class="label">Transaction Hash</div>
<sm-copy value=${txDetails.hash}></sm-copy>
</div>
<div class="grid gap-0-5">
<div class="label">From</div>
<sm-copy value=${txDetails.from}></sm-copy>
</div>
<div class="grid gap-0-5">
<div class="label">To</div>
<sm-copy value=${txDetails.to}></sm-copy>
</div>
${txDetails.tokenTransfer ? html`
<div class="grid gap-0-5">
<div class="label">Token Transfer</div>
<strong>${txDetails.tokenTransfer.value} ${txDetails.tokenTransfer.symbol}</strong>
</div>
` : html`
<div class="grid gap-0-5">
<div class="label">Value</div>
<strong>${txDetails.value} ETH</strong>
</div>
`}
<div class="grid gap-0-5">
<div class="label">Gas Fee</div>
<strong>${txDetails.gasFee ? txDetails.gasFee.toFixed(8) : 'N/A'} ETH</strong>
</div>
<div class="grid gap-0-5">
<div class="label">Block Number</div>
<strong>${txDetails.blockNumber || 'Pending'}</strong>
</div>
<div class="grid gap-0-5">
<div class="label">Confirmations</div>
<strong>${txDetails.confirmations}</strong>
</div>
<div class="grid gap-0-5">
<div class="label">Timestamp</div>
<strong>${formattedDate}</strong>
</div>
<a class="button button--primary" target="_blank" href=${'https://etherscan.io/tx/' + txHash}>
View on Etherscan
</a>
</div>
`);
getRef('eth_balance_wrapper').classList.remove('hidden');
} catch (error) {
console.error('Error in viewTransactionDetails:', error);
notify(error.message || 'Failed to fetch transaction details', 'error');
} finally {
buttonLoader('check_balance_button', false);
}
}
// Function to handle URL parameters and load appropriate data
function handleUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
// Check for transaction hash parameter
const txHash = urlParams.get('tx');
if (txHash && /^0x[0-9a-fA-F]{64}$/.test(txHash)) {
// Populate input with tx hash
const searchInput = document.querySelector('#check_balance_input');
if (searchInput) {
searchInput.value = txHash;
const nativeInput = searchInput.querySelector('input');
if (nativeInput) {
nativeInput.value = txHash;
}
}
// viewTransactionDetails handles its own loading state
viewTransactionDetails(txHash);
return;
}
// Check for address parameter
const address = urlParams.get('address');
if (address && ethOperator.isValidAddress(address)) {
// Populate the input field - target both custom element and native input
const searchInput = document.querySelector('#check_balance_input');
const nativeInput = searchInput?.querySelector('input');
if (nativeInput) {
nativeInput.value = address;
nativeInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (searchInput) {
searchInput.value = address;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
// Load the address (checkBalance handles its own loading state)
checkBalance(address);
} else {
// No parameters, hide the balance wrapper
const balanceWrapper = getRef('eth_balance_wrapper');
if (balanceWrapper) {
balanceWrapper.classList.add('hidden');
}
}
}
// Handle browser back/forward navigation
window.addEventListener('popstate', () => {
// Check if we're on the balance page
if (window.location.hash.includes('balance') || window.location.hash === '#/' || window.location.hash === '') {
handleUrlParams();
}
});
function handleInvalidSearch() {
if (document.startViewTransition)
document.startViewTransition(() => {
@ -861,7 +1271,7 @@
<div class="grid gap-0-5">
<sm-input class="receiver-address" placeholder="Receiver's Ethereum address" data-eth-address animate required ></sm-input>
<div class="flex flex-direction-column gap-0-5">
<sm-input class="receiver-amount amount-shown flex-1" placeholder="Amount" type="number" step="0.000001" min="0.000001" error-text="Amount should be grater than 0.000001 ETHER" animate required>
<sm-input class="receiver-amount amount-shown flex-1" placeholder="Amount" type="number" step="0.000001" min="0.000001" error-text="Amount should be greater than 0.000001 ETHER" animate required>
<div class="asset-symbol flex" slot="icon">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"> <g clip-path="url(#clip0_201_2)"> <path d="M12 0L19.6368 12.4368L12.1633 16.8L4.36325 12.4368L12 0Z"/> <path d="M12 24L4.36325 13.6099L11.8367 18L19.6368 13.6099L12 24Z"/> </g> <defs> <clipPath id="clip0_201_2"> <rect width="24" height="24" fill="white"/> </clipPath> </defs> </svg>
</div>
@ -932,7 +1342,7 @@
const asset = e.target.value
const amountInput = getRef('send_tx_form').querySelector('.receiver-amount')
amountInput.value = ''
amountInput.setAttribute('error-text', `Amount should be grater than 0.000001 ${asset.toUpperCase()}`)
amountInput.setAttribute('error-text', `Amount should be greater than 0.000001 ${asset.toUpperCase()}`)
document.querySelectorAll('.asset-symbol').forEach(elem => {
elem.innerHTML = assetIcons[asset]
})
@ -942,60 +1352,229 @@
const receiver = getRef('send_tx_form').querySelector('.receiver-address').value.trim();
const amount = getRef('send_tx_form').querySelector('.receiver-amount').value.trim();
const asset = getRef('asset_selector').value;
try {
const confirmation = await getConfirmation('Send transaction', {
message: `You are about to send ${amount} ${asset.toUpperCase()} to ${receiver}`,
confirmText: 'Send',
})
buttonLoader('send_tx_button', true)
if (!confirmation) return
let privateKey = getRef('private_key_input').value.trim()
// First, get basic confirmation
const initialConfirmation = await getConfirmation('Confirm transaction', {
message: `Calculating gas fees for sending ${amount} ${asset.toUpperCase()} to ${receiver}...`,
confirmText: 'Continue',
});
if (!initialConfirmation) return;
// Show loading state while calculating gas
buttonLoader('send_tx_button', true);
// Get private key
let privateKey = getRef('private_key_input').value.trim();
if (/^[0-9a-fA-F]{64}$/.test(privateKey)) {
privateKey = coinjs.privkey2wif(privateKey)
privateKey = coinjs.privkey2wif(privateKey);
}
privateKey = coinjs.wif2privkey(privateKey).privkey;
switch (asset) {
case 'ether': {
const tx = await ethOperator.sendTransaction({
// Calculate gas fees
let gasEstimate, feeData, estimatedGasFee, maxGasFee, totalCostETH;
try {
// Get provider for gas estimation
const provider = ethOperator.getProvider(true);
if (!provider) throw new Error('Provider not available');
// Get fee data
feeData = await provider.getFeeData();
// Estimate gas limit
if (asset === 'ether') {
gasEstimate = await ethOperator.estimateGas({
privateKey,
receiver,
amount,
})
showTransactionResult('pending', { txHash: tx.hash })
await tx.wait()
showTransactionResult('confirmed', { txHash: tx.hash })
break;
amount
});
} else {
// For token transfers, estimate is typically higher
gasEstimate = ethers.BigNumber.from('65000'); // Typical ERC20 transfer gas
}
case 'usdc':
case 'usdt': {
const tx = await ethOperator.sendToken({
privateKey,
receiver,
amount,
token: asset
})
showTransactionResult('pending', { txHash: tx.hash })
await tx.wait()
showTransactionResult('confirmed', { txHash: tx.hash })
break;
// Calculate priority fee and max fee
const priorityFee = feeData.maxPriorityFeePerGas || ethers.utils.parseUnits("1.5", "gwei");
let maxFee = feeData.maxFeePerGas;
if (!maxFee || maxFee.lt(priorityFee)) {
const block = await provider.getBlock("latest");
const baseFee = block.baseFeePerGas || ethers.utils.parseUnits("1", "gwei");
maxFee = baseFee.mul(2).add(priorityFee);
}
const minMaxFee = priorityFee.mul(15).div(10);
if (maxFee.lt(minMaxFee)) {
maxFee = minMaxFee;
}
// Calculate estimated gas fee (using base fee + priority fee for estimation)
const block = await provider.getBlock("latest");
const baseFee = block.baseFeePerGas || ethers.utils.parseUnits("1", "gwei");
const estimatedGasPrice = baseFee.add(priorityFee);
estimatedGasFee = parseFloat(ethers.utils.formatEther(gasEstimate.mul(estimatedGasPrice)));
// Calculate max possible gas fee
maxGasFee = parseFloat(ethers.utils.formatEther(gasEstimate.mul(maxFee)));
// Calculate total cost in ETH
totalCostETH = asset === 'ether' ? (parseFloat(amount) + estimatedGasFee) : estimatedGasFee;
} catch (gasError) {
console.error('Gas estimation error:', gasError);
buttonLoader('send_tx_button', false);
notify('Failed to estimate gas fees. Please try again.', 'error');
return;
}
getRef('send_tx_form').reset()
getRef('sender_balance_container').classList.add('hidden')
buttonLoader('send_tx_button', false);
// Show detailed confirmation with gas fees
const gasConfirmationPopup = html.node`
<sm-popup id="gas_confirmation_popup">
<header slot="header" class="popup__header">
<h4>Review Transaction</h4>
</header>
<div class="grid gap-1-5" style="padding: 1.5rem;">
<div class="grid gap-1">
<h5>Transaction Details</h5>
<div class="grid gap-0-5" style="padding: 1rem; border-radius: 0.5rem; background: rgba(var(--text-color), 0.06);">
<div class="flex space-between">
<span class="label">Amount:</span>
<strong class="amount-shown">${amount} ${asset.toUpperCase()}</strong>
</div>
<div class="flex space-between">
<span class="label">To:</span>
<span style="font-family: monospace; font-size: 0.9rem;">${receiver.slice(0, 10)}...${receiver.slice(-8)}</span>
</div>
</div>
</div>
<div class="grid gap-1">
<h5>Gas Fee Estimate</h5>
<div class="grid gap-0-5" style="padding: 1rem; border-radius: 0.5rem; background: rgba(var(--text-color), 0.06);">
<div class="flex space-between">
<span class="label">Estimated Gas:</span>
<strong class="amount-shown">${estimatedGasFee.toFixed(6)} ETH</strong>
</div>
<div class="flex space-between">
<span class="label">Max Gas Fee:</span>
<span class="amount-shown" style="color: rgba(var(--text-color), 0.7);">${maxGasFee.toFixed(6)} ETH</span>
</div>
<div class="flex space-between">
<span class="label">Gas Limit:</span>
<span>${gasEstimate.toString()}</span>
</div>
<hr style="border: none; border-top: 1px solid rgba(var(--text-color), 0.1); margin: 0.5rem 0;">
<div class="flex space-between">
<strong>Total Cost (Est.):</strong>
<strong class="amount-shown">${totalCostETH.toFixed(6)} ETH</strong>
</div>
</div>
<p style="font-size: 0.875rem; color: rgba(var(--text-color), 0.7);">
<svg class="icon" style="width: 16px; height: 16px; vertical-align: middle;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
Gas fees vary based on network congestion. The actual fee may be lower than estimated.
</p>
</div>
<div class="flex gap-0-5">
<button class="button flex-1" onclick="closeGasConfirmation()">Cancel</button>
<button class="button button--primary flex-1" onclick="confirmAndSend()">Confirm & Send</button>
</div>
</div>
</sm-popup>
`;
document.body.appendChild(gasConfirmationPopup);
// Store transaction data for confirmation
window.pendingTxData = {
receiver,
amount,
asset,
privateKey
};
// Define close function
window.closeGasConfirmation = () => {
closePopup();
setTimeout(() => {
gasConfirmationPopup.remove();
delete window.pendingTxData;
delete window.closeGasConfirmation;
delete window.confirmAndSend;
}, 300);
};
// Define confirm and send function
window.confirmAndSend = async () => {
closePopup();
gasConfirmationPopup.remove();
const { receiver, amount, asset, privateKey } = window.pendingTxData;
delete window.pendingTxData;
delete window.closeGasConfirmation;
delete window.confirmAndSend;
buttonLoader('send_tx_button', true);
try {
switch (asset) {
case 'ether': {
const tx = await ethOperator.sendTransaction({
privateKey,
receiver,
amount,
});
showTransactionResult('pending', { txHash: tx.hash });
await tx.wait();
showTransactionResult('confirmed', { txHash: tx.hash });
break;
}
case 'usdc':
case 'usdt': {
const tx = await ethOperator.sendToken({
privateKey,
receiver,
amount,
token: asset
});
showTransactionResult('pending', { txHash: tx.hash });
await tx.wait();
showTransactionResult('confirmed', { txHash: tx.hash });
break;
}
}
getRef('send_tx_form').reset();
getRef('sender_balance_container').classList.add('hidden');
} catch (e) {
console.error(e.message);
const regex = /\(error=({.*?}),/;
const match = e.message.match(regex);
if (match && match[1]) {
const { code } = JSON.parse(match[1]);
if (code === -32000)
showTransactionResult('failed', { description: `Insufficient ${asset.toUpperCase()} balance` });
else {
showTransactionResult('failed', { description: e.message });
}
} else {
showTransactionResult('failed', { description: e.message });
}
} finally {
buttonLoader('send_tx_button', false);
}
};
openPopup('gas_confirmation_popup');
} catch (e) {
console.error(e.message)
const regex = /\(error=({.*?}),/;
const match = e.message.match(regex);
if (match && match[1]) {
const { code } = JSON.parse(match[1]);
if (code === -32000)
showTransactionResult('failed', { description: `Insufficient ${asset.toUpperCase()} balance` })
else {
showTransactionResult('failed', { description: e.message })
}
}
} finally {
buttonLoader('send_tx_button', false)
console.error(e);
notify(e.message || 'Transaction failed', 'error');
buttonLoader('send_tx_button', false);
}
}
//Show transaction phase

View File

@ -1,4 +1,4 @@
(function (EXPORTS) { //ethOperator v1.0.2
(function (EXPORTS) { // ethOperator v1.0.2
/* ETH Crypto and API Operator */
if (!window.ethers)
return console.error('ethers.js not found')
@ -240,61 +240,51 @@
usdc: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
usdt: "0xdac17f958d2ee523a2206206994597c13d831ec7"
}
function getProvider() {
// switches provider based on whether the user is using MetaMask or not
if (window.ethereum) {
/**
* Get Ethereum provider (MetaMask or public RPC)
* @param {boolean} readOnly - If true, use public RPC; if false, use MetaMask when available
* @returns {ethers.providers.Provider} Ethereum provider instance
*/
const getProvider = ethOperator.getProvider = (readOnly = false) => {
if (!readOnly && window.ethereum) {
return new ethers.providers.Web3Provider(window.ethereum);
} else {
return new ethers.providers.JsonRpcProvider(`https://mainnet.infura.io/v3/6e12fee52bdd48208f0d82fb345bcb3c`)
}
}
function connectToMetaMask() {
return new Promise((resolve, reject) => {
// if (typeof window.ethereum === "undefined")
// return reject("MetaMask not installed");
return resolve(true)
ethereum
.request({ method: 'eth_requestAccounts' })
.then((accounts) => {
console.log('Connected to MetaMask')
return resolve(accounts)
})
.catch((err) => {
console.log(err)
return reject(err)
})
})
}
// connectToMetaMask();
// Note: MetaMask connection is handled in the UI layer, not here
const getBalance = ethOperator.getBalance = async (address) => {
try {
if (!address || !isValidAddress(address))
return new Error('Invalid address');
// Get the balance
const provider = getProvider();
// Use read-only provider (public RPC) for balance checks
const provider = getProvider(true);
const balanceWei = await provider.getBalance(address);
const balanceEth = parseFloat(ethers.utils.formatEther(balanceWei));
return balanceEth;
} catch (error) {
console.error('Error:', error.message);
return error;
console.error('Balance error:', error.message);
return 0;
}
}
const getTokenBalance = ethOperator.getTokenBalance = async (address, token, { contractAddress } = {}) => {
try {
// if (!window.ethereum.isConnected()) {
// await connectToMetaMask();
// }
if (!token)
return new Error("Token not specified");
if (!CONTRACT_ADDRESSES[token] && contractAddress)
return new Error('Contract address of token not available')
const usdcContract = new ethers.Contract(CONTRACT_ADDRESSES[token] || contractAddress, ERC20ABI, getProvider());
let balance = await usdcContract.balanceOf(address);
balance = parseFloat(ethers.utils.formatUnits(balance, 6)); // Assuming 6 decimals
// Use read-only provider (public RPC) for token balance checks
const provider = getProvider(true);
const tokenAddress = CONTRACT_ADDRESSES[token] || contractAddress;
const tokenContract = new ethers.Contract(tokenAddress, ERC20ABI, provider);
let balance = await tokenContract.balanceOf(address);
balance = parseFloat(ethers.utils.formatUnits(balance, 6)); // USDC and USDT use 6 decimals
return balance;
} catch (e) {
console.error(e);
console.error('Token balance error:', e);
return 0;
}
}
@ -317,28 +307,269 @@
const provider = getProvider();
const signer = new ethers.Wallet(privateKey, provider);
const limit = await estimateGas({ privateKey, receiver, amount })
// Get current fee data from the network
const feeData = await provider.getFeeData();
// Calculate priority fee (tip to miners) - use 1.5 gwei or the network's suggested priority fee, whichever is higher
const priorityFee = feeData.maxPriorityFeePerGas || ethers.utils.parseUnits("1.5", "gwei");
// Calculate max fee per gas (base fee + priority fee)
// Use the network's suggested maxFeePerGas or calculate it manually
let maxFee = feeData.maxFeePerGas;
// If maxFeePerGas is not available or is less than priority fee, calculate it
if (!maxFee || maxFee.lt(priorityFee)) {
// Get the base fee from the latest block and add our priority fee
const block = await provider.getBlock("latest");
const baseFee = block.baseFeePerGas || ethers.utils.parseUnits("1", "gwei");
// maxFee = (baseFee * 2) + priorityFee to account for potential base fee increases
maxFee = baseFee.mul(2).add(priorityFee);
}
// Ensure maxFee is at least 1.5x the priority fee for safety
const minMaxFee = priorityFee.mul(15).div(10); // 1.5x priority fee
if (maxFee.lt(minMaxFee)) {
maxFee = minMaxFee;
}
// Creating and sending the transaction object
return signer.sendTransaction({
to: receiver,
value: ethers.utils.parseUnits(amount, "ether"),
gasLimit: limit,
nonce: signer.getTransactionCount(),
maxPriorityFeePerGas: ethers.utils.parseUnits("2", "gwei"),
nonce: await signer.getTransactionCount(),
maxPriorityFeePerGas: priorityFee,
maxFeePerGas: maxFee,
})
} catch (e) {
throw new Error(e)
}
}
/**
* Send ERC20 tokens (USDC or USDT)
* @param {object} params - Transaction parameters
* @param {string} params.token - Token symbol ('usdc' or 'usdt')
* @param {string} params.privateKey - Sender's private key
* @param {string} params.amount - Amount to send
* @param {string} params.receiver - Recipient's Ethereum address
* @param {string} params.contractAddress - Optional custom contract address
* @returns {Promise} Transaction promise
*/
const sendToken = ethOperator.sendToken = async ({ token, privateKey, amount, receiver, contractAddress }) => {
// Create a wallet using the private key
const wallet = new ethers.Wallet(privateKey, getProvider());
// Contract interface
const tokenContract = new ethers.Contract(CONTRACT_ADDRESSES[token] || contractAddress, ERC20ABI, wallet);
// Convert the amount to the smallest unit of USDC (wei)
const amountWei = ethers.utils.parseUnits(amount.toString(), 6); // Assuming 6 decimals for USDC
// Call the transfer function on the USDC contract
// Convert amount to smallest unit (both USDC and USDT use 6 decimals)
const amountWei = ethers.utils.parseUnits(amount.toString(), 6);
return tokenContract.transfer(receiver, amountWei)
}
const ETHERSCAN_API_KEY = 'M3YBAHI21FVE7VS2FEKU6ZFGRA128WUVQK';
/**
* Get transaction history for an Ethereum address
* @param {string} address - Ethereum address
* @param {object} options - Optional parameters
* @returns {Promise<Array>} Array of transactions
*/
const getTransactionHistory = ethOperator.getTransactionHistory = async (address, options = {}) => {
try {
if (!address || !isValidAddress(address)) {
throw new Error('Invalid Ethereum address');
}
const {
startBlock = 0,
endBlock = 99999999,
page = 1,
offset = 100,
sort = 'desc'
} = options;
// Fetch normal transactions using V2 API
const normalTxUrl = `https://api.etherscan.io/v2/api?chainid=1&module=account&action=txlist&address=${address}&startblock=${startBlock}&endblock=${endBlock}&page=${page}&offset=${offset}&sort=${sort}&apikey=${ETHERSCAN_API_KEY}`;
const normalTxResponse = await fetch(normalTxUrl);
const normalTxData = await normalTxResponse.json();
if (normalTxData.status !== '1') {
if (normalTxData.message === 'No transactions found') {
return [];
}
// Provide more detailed error messages
if (normalTxData.result && normalTxData.result.includes('Invalid API Key')) {
throw new Error('Invalid Etherscan API Key. Please check your API key.');
}
if (normalTxData.result && normalTxData.result.includes('Max rate limit reached')) {
throw new Error('Etherscan API rate limit reached. Please try again later.');
}
throw new Error(`Etherscan API Error: ${normalTxData.message || normalTxData.result || 'Failed to fetch transactions'}`);
}
// Fetch ERC20 token transfers using V2 API
const tokenTxUrl = `https://api.etherscan.io/v2/api?chainid=1&module=account&action=tokentx&address=${address}&startblock=${startBlock}&endblock=${endBlock}&page=${page}&offset=${offset}&sort=${sort}&apikey=${ETHERSCAN_API_KEY}`;
const tokenTxResponse = await fetch(tokenTxUrl);
const tokenTxData = await tokenTxResponse.json();
const tokenTransfers = tokenTxData.status === '1' ? tokenTxData.result : [];
// Combine and sort transactions
const allTransactions = [...normalTxData.result, ...tokenTransfers];
// Sort by timestamp (descending)
allTransactions.sort((a, b) => parseInt(b.timeStamp) - parseInt(a.timeStamp));
// Parse and format transactions
return allTransactions.map(tx => {
const isTokenTransfer = tx.tokenSymbol !== undefined;
const isReceived = tx.to.toLowerCase() === address.toLowerCase();
let value, symbol, decimals;
if (isTokenTransfer) {
decimals = parseInt(tx.tokenDecimal) || 18;
value = parseFloat(ethers.utils.formatUnits(tx.value, decimals));
symbol = tx.tokenSymbol || 'TOKEN';
} else {
value = parseFloat(ethers.utils.formatEther(tx.value));
symbol = 'ETH';
}
return {
hash: tx.hash,
from: tx.from,
to: tx.to,
value: value,
symbol: symbol,
timestamp: parseInt(tx.timeStamp),
blockNumber: parseInt(tx.blockNumber),
isReceived: isReceived,
isSent: !isReceived,
gasUsed: tx.gasUsed ? parseInt(tx.gasUsed) : 0,
gasPrice: tx.gasPrice ? parseFloat(ethers.utils.formatUnits(tx.gasPrice, 'gwei')) : 0,
isError: tx.isError === '1' || tx.txreceipt_status === '0',
contractAddress: tx.contractAddress || null,
tokenName: tx.tokenName || null,
confirmations: tx.confirmations ? parseInt(tx.confirmations) : 0,
nonce: tx.nonce ? parseInt(tx.nonce) : 0,
input: tx.input || '0x',
isTokenTransfer: isTokenTransfer
};
});
} catch (error) {
console.error('Error fetching transaction history:', error);
throw error;
}
};
/**
* Get detailed information about a specific transaction
* @param {string} txHash - Transaction hash
* @returns {Promise<Object>} Transaction details
*/
const getTransactionDetails = ethOperator.getTransactionDetails = async (txHash) => {
try {
if (!txHash || !/^0x([A-Fa-f0-9]{64})$/.test(txHash)) {
throw new Error('Invalid transaction hash');
}
// Use read-only provider for fetching transaction details
const provider = getProvider(true);
// Get transaction details
const tx = await provider.getTransaction(txHash);
if (!tx) {
throw new Error('Transaction not found');
}
// Get transaction receipt for status and gas used
const receipt = await provider.getTransactionReceipt(txHash);
// Get current block number for confirmations
const currentBlock = await provider.getBlockNumber();
// Get block details for timestamp
const block = await provider.getBlock(tx.blockNumber);
// Calculate gas fee
const gasUsed = receipt ? receipt.gasUsed : null;
const effectiveGasPrice = receipt ? receipt.effectiveGasPrice : tx.gasPrice;
const gasFee = gasUsed && effectiveGasPrice ?
parseFloat(ethers.utils.formatEther(gasUsed.mul(effectiveGasPrice))) : null;
// Check if it's a token transfer by examining logs
let tokenTransfer = null;
if (receipt && receipt.logs.length > 0) {
// Try to decode ERC20 Transfer event
const transferEventSignature = ethers.utils.id('Transfer(address,address,uint256)');
const transferLog = receipt.logs.find(log => log.topics[0] === transferEventSignature);
if (transferLog) {
try {
const tokenContract = new ethers.Contract(transferLog.address, ERC20ABI, provider);
const [symbol, decimals] = await Promise.all([
tokenContract.symbol().catch(() => 'TOKEN'),
tokenContract.decimals().catch(() => 18)
]);
const from = ethers.utils.getAddress('0x' + transferLog.topics[1].slice(26));
const to = ethers.utils.getAddress('0x' + transferLog.topics[2].slice(26));
const value = parseFloat(ethers.utils.formatUnits(transferLog.data, decimals));
tokenTransfer = {
from,
to,
value,
symbol,
contractAddress: transferLog.address
};
} catch (e) {
console.warn('Could not decode token transfer:', e);
}
}
}
return {
hash: tx.hash,
from: tx.from,
to: tx.to,
value: parseFloat(ethers.utils.formatEther(tx.value)),
symbol: 'ETH',
blockNumber: tx.blockNumber,
timestamp: block ? block.timestamp : null,
confirmations: currentBlock - tx.blockNumber,
gasLimit: tx.gasLimit.toString(),
gasUsed: gasUsed ? gasUsed.toString() : null,
gasPrice: parseFloat(ethers.utils.formatUnits(tx.gasPrice, 'gwei')),
gasFee: gasFee,
nonce: tx.nonce,
input: tx.data,
status: receipt ? (receipt.status === 1 ? 'success' : 'failed') : 'pending',
isError: receipt ? receipt.status !== 1 : false,
tokenTransfer: tokenTransfer,
logs: receipt ? receipt.logs : [],
type: tx.type
};
} catch (error) {
console.error('Error fetching transaction details:', error);
throw error;
}
};
/**
* Check if a string is a valid transaction hash
* @param {string} hash - Potential transaction hash
* @returns {boolean}
*/
const isValidTxHash = ethOperator.isValidTxHash = (hash) => {
return /^0x([A-Fa-f0-9]{64})$/.test(hash);
};
})('object' === typeof module ? module.exports : window.ethOperator = {});