From 2934f06846790b0a4e336971672232e54636e4c1 Mon Sep 17 00:00:00 2001 From: void-57 Date: Sun, 9 Nov 2025 05:22:20 +0530 Subject: [PATCH] refactor transaction history retrieval and enhance transaction detail formatting --- index.html | 23 +-- style.css | 7 +- suiBlockchainAPI.js | 477 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 386 insertions(+), 121 deletions(-) diff --git a/index.html b/index.html index a1a5fe1..cf7f622 100644 --- a/index.html +++ b/index.html @@ -1088,11 +1088,6 @@ if (privateKey && recipientAddress && validators.isPrivateKey(privateKey) && validators.isSuiAddress(recipientAddress)) { try { const wallet = await suiCrypto.generateMultiChain(privateKey); - if (wallet.SUI.address.toLowerCase() === recipientAddress.toLowerCase()) { - recipientInput.classList.add('error'); - errors.push('Cannot send to the same address'); - hasErrors = true; - } } catch (error) { console.error('Error validating addresses:', error); } @@ -1625,6 +1620,8 @@ currentAddress = address; currentPage = 1; + // Clear the cache when searching for a new address + cache = {}; // Get balance and display const balance = await suiBlockchainAPI.getBalance(address, '0x2::sui::SUI'); @@ -1689,19 +1686,13 @@ resultsDiv.innerHTML = '
Loading transactions...
'; - let useCursor = cursor; - if (page > 1 && cache[page - 1]) { - useCursor = cache[page - 1].nextCursor; - } - try { - const result = await suiBlockchainAPI.getTransactionHistory(currentAddress, useCursor, 10); + const result = await suiBlockchainAPI.getTransactionHistory(currentAddress, page, 10); const hasNextPage = result.hasNextPage; - const nextCursor = result.nextCursor; // Store in cache - cache[page] = { details: result.txs, hasNextPage, nextCursor }; + cache[page] = { details: result.txs, hasNextPage, nextCursor: null }; // Render transactions renderTransactionPage(cache[page]); @@ -1753,11 +1744,11 @@
- +
-
${tx.direction}
-
${tx.direction === 'Sent' ? '-' : '+'}${tx.amountSui} SUI
+
${tx.direction === 'Self' ? 'Self Transfer' : tx.direction}
+
${tx.direction === 'Sent' ? '-' : tx.direction === 'Received' ? '+' : ''}${tx.amount} ${tx.symbol}
diff --git a/style.css b/style.css index 5773f13..2fe9582 100644 --- a/style.css +++ b/style.css @@ -566,7 +566,7 @@ body { .main-content { flex: 1; padding: 2rem; - padding-bottom: 140px; /* Space for mobile navigation */ + padding-bottom: 140px; width: 100%; max-width: 100%; overflow-x: hidden; @@ -1468,6 +1468,9 @@ body { color: #10b981; } +.tx-amount.self { + color: #8b5cf6; +} .tx-details { display: flex; flex-direction: column; @@ -1986,7 +1989,7 @@ body { } .tx-icon { - display: none; /* Hide icon on mobile to save space */ + display: none; } /* Pagination Mobile */ diff --git a/suiBlockchainAPI.js b/suiBlockchainAPI.js index 6a782fa..5eb2d24 100644 --- a/suiBlockchainAPI.js +++ b/suiBlockchainAPI.js @@ -15,72 +15,284 @@ const suiBlockchainAPI = { return json?.result?.totalBalance || 0; }, // Get Transaction History - async getTransactionHistory(address, cursor = null, limit = 10) { + async getTransactionHistory(address, page = 1, limit = 10) { const SUI_RPC_URL = "https://fullnode.mainnet.sui.io:443"; try { - //Query transaction digests using ToAddress filter - const res = await fetch(SUI_RPC_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "suix_queryTransactionBlocks", - params: [ - { - filter: { ToAddress: address }, - }, - cursor, - limit, - true, - ], - }), - }); + const requiredTransactions = page * limit; + const smartBatchSize = requiredTransactions + 20; + console.log( + `Page ${page}: Smart batching - need ${requiredTransactions}, fetching ${smartBatchSize}` + ); - const json = await res.json(); - if (json.error) throw new Error(json.error.message); + // Get both sent (FromAddress) and received (ToAddress) transactions + let allFromDigests = []; + let allToDigests = []; + let fromCursor = null; + let toCursor = null; + let fromHasMore = true; + let toHasMore = true; - const digests = json.result?.data?.map((d) => d.digest) || []; - const nextCursor = json.result?.nextCursor || null; - const hasNextPage = !!json.result?.hasNextPage; + // Keep a set of unique digests while fetching. + const uniqueDigestSet = new Set(); + const digestToTimestamp = new Map(); // Store timestamps from queryTransactionBlocks + let safetyCounter = 0; + const MAX_FETCH_ROUNDS = 10; - //Fetch detailed information for each transaction - const details = await Promise.all( - digests.map(async (digest) => { - try { - const detailRes = await fetch(SUI_RPC_URL, { + while ( + (fromHasMore || toHasMore) && + uniqueDigestSet.size < smartBatchSize && + safetyCounter < MAX_FETCH_ROUNDS + ) { + safetyCounter++; + const requests = []; + + if (fromHasMore) { + requests.push( + fetch(SUI_RPC_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, - method: "sui_getTransactionBlock", + method: "suix_queryTransactionBlocks", params: [ - digest, { - showInput: true, - showEffects: true, - showBalanceChanges: true, - showEvents: true, + filter: { FromAddress: address }, + options: { showInput: true, showEffects: true }, }, + fromCursor, + Math.min(25, smartBatchSize - allFromDigests.length), + true, ], }), - }); - const detailJson = await detailRes.json(); - return detailJson.result; - } catch (err) { - console.warn(`Failed to fetch details for digest ${digest}:`, err); - return null; - } - }) + }) + ); + } + + // Fetch TO transactions if we still have more + if (toHasMore) { + requests.push( + fetch(SUI_RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "suix_queryTransactionBlocks", + params: [ + { + filter: { ToAddress: address }, + options: { showInput: true, showEffects: true }, + }, + toCursor, + Math.min(25, smartBatchSize - allToDigests.length), + true, + ], + }), + }) + ); + } + + const responses = await Promise.all(requests); + let responseIndex = 0; + + // Process FROM response + if (fromHasMore && responses[responseIndex]) { + const fromJson = await responses[responseIndex].json(); + responseIndex++; + const newFromDigests = + fromJson.result?.data?.map((d) => d.digest) || []; + allFromDigests.push(...newFromDigests); + + // Store timestamps from queryTransactionBlocks + fromJson.result?.data?.forEach((tx) => { + if (tx.digest && tx.timestampMs) { + digestToTimestamp.set(tx.digest, Number(tx.timestampMs)); + } + }); + + // Add to unique set + newFromDigests.forEach((dg) => uniqueDigestSet.add(dg)); + fromCursor = fromJson.result?.nextCursor; + fromHasMore = + !!fromJson.result?.hasNextPage && newFromDigests.length > 0; + console.log( + `Page ${page}: FROM batch - got ${newFromDigests.length}, total ${allFromDigests.length}, unique ${uniqueDigestSet.size}, hasMore: ${fromHasMore}` + ); + } + + // Process TO response + if (toHasMore && responses[responseIndex]) { + const toJson = await responses[responseIndex].json(); + const newToDigests = toJson.result?.data?.map((d) => d.digest) || []; + allToDigests.push(...newToDigests); + + // Store timestamps from queryTransactionBlocks + toJson.result?.data?.forEach((tx) => { + if (tx.digest && tx.timestampMs) { + digestToTimestamp.set(tx.digest, Number(tx.timestampMs)); + } + }); + + // Add to unique set + newToDigests.forEach((dg) => uniqueDigestSet.add(dg)); + toCursor = toJson.result?.nextCursor; + toHasMore = !!toJson.result?.hasNextPage && newToDigests.length > 0; + console.log( + `Page ${page}: TO batch - got ${newToDigests.length}, total ${allToDigests.length}, unique ${uniqueDigestSet.size}, hasMore: ${toHasMore}` + ); + } + + if (requests.length === 0) break; + } + + console.log( + `Page ${page}: Final totals - FROM=${allFromDigests.length}, TO=${allToDigests.length} digests` + ); + // Use the unique set as the deduplicated result + const uniqueDigests = [...uniqueDigestSet]; + + const mightHaveMorePages = fromHasMore || toHasMore; + console.log( + `Page ${page}: HasNextPage - FROM: ${fromHasMore}, TO: ${toHasMore}` ); - // Remove null results - const validDetails = details.filter(Boolean); + let digests = uniqueDigests; + console.log( + `Page ${page}: Got ${digests.length} unique digests from API` + ); - // Sort by timestamp descending (newest first) - validDetails.sort((a, b) => (b.timestampMs || 0) - (a.timestampMs || 0)); + const neededForPage = page * limit; + + // Fetch ALL transaction details first, then sort globally by timestamp + console.log( + `Page ${page}: Fetching details for ALL ${digests.length} transactions for global time-sort` + ); + + // Convert Set to Array for processing + const allUniqueDigests = Array.from(uniqueDigestSet); + const allDetails = []; + const BATCH_SIZE = 20; + + // Fetch transaction details for ALL unique digests + for (let i = 0; i < allUniqueDigests.length; i += BATCH_SIZE) { + const batch = allUniqueDigests.slice(i, i + BATCH_SIZE); + console.log( + `Page ${page}: Processing batch ${Math.floor(i / BATCH_SIZE) + 1}: ${ + batch.length + } transactions` + ); + + try { + // Use sui_multiGetTransactionBlocks + const batchRes = await fetch(SUI_RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "sui_multiGetTransactionBlocks", + params: [ + batch, // Array of digests + { + showInput: true, + showRawInput: false, + showEffects: true, + showEvents: true, + showObjectChanges: false, + showBalanceChanges: true, + }, + ], + }), + }); + + if (!batchRes.ok) { + console.warn(`HTTP ${batchRes.status} for batch starting at ${i}`); + continue; + } + + const batchJson = await batchRes.json(); + + if (batchJson.error) { + console.warn(`Batch API error:`, batchJson.error); + continue; + } + + // Filter out null results and add missing timestamps + const validResults = batchJson.result.filter(Boolean); + + // Ensure every transaction has a timestamp + validResults.forEach((tx) => { + if (!tx.timestampMs && digestToTimestamp.has(tx.digest)) { + tx.timestampMs = digestToTimestamp.get(tx.digest); + } + }); + + allDetails.push(...validResults); + + console.log( + `Page ${page}: Batch ${Math.floor(i / BATCH_SIZE) + 1}: Got ${ + validResults.length + }/${batch.length} valid transactions` + ); + } catch (error) { + console.error(`Batch fetch error for batch starting at ${i}:`, error); + } + + if (i + BATCH_SIZE < allUniqueDigests.length) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + console.log( + `Page ${page}: Fetched ${allDetails.length} total transaction details` + ); + + allDetails.sort((a, b) => { + const t1 = Number(a.timestampMs || 0); + const t2 = Number(b.timestampMs || 0); + return t2 - t1; // Newest first + }); + + console.log( + `Page ${page}: Globally sorted ${allDetails.length} transactions by timestamp` + ); + + const startIndex = (page - 1) * limit; + const endIndex = page * limit; + const validDetails = allDetails.slice(startIndex, endIndex); + + console.log( + `Page ${page}: Showing ${validDetails.length} transactions for this page` + ); + + + if (validDetails.length === 0 && page > 1) { + console.warn(`Page ${page}: No transactions available for this page`); + return { + txs: [], + hasNextPage: false, + nextCursor: { + from: fromCursor, + to: toCursor, + fromHasMore, + toHasMore, + }, + }; + } + + console.log( + `Page ${page}: Returning ${validDetails.length} transactions (globally sorted)` + ); + + + const totalAvailableTransactions = allDetails.length; + const hasNextPage = + endIndex < totalAvailableTransactions || mightHaveMorePages; + console.log( + `Page ${page}: hasNextPage = ${hasNextPage} (total: ${totalAvailableTransactions}, endIndex: ${endIndex}, mightHaveMorePages: ${mightHaveMorePages})` + ); // Transform to the expected format const transactions = validDetails.map((tx) => { @@ -88,78 +300,113 @@ const suiBlockchainAPI = { const balanceChanges = tx.balanceChanges || []; const status = tx.effects?.status?.status || "unknown"; - // Find recipient and amount from transaction inputs first let to = "Unknown"; - let amountMist = 0; + let amountRaw = 0; + let coinType = "0x2::sui::SUI"; // Default to SUI - const inputs = tx.transaction?.data?.transaction?.inputs || []; + let maxChangeAmount = 0; + for (const change of balanceChanges) { + const changeAmount = Math.abs(Number(change.amount || 0)); + const changeOwner = change.owner?.AddressOwner; + const changeCoinType = change.coinType || "0x2::sui::SUI"; - // Find the address input (recipient) - const addressInput = inputs.find( - (input) => input.type === "pure" && input.valueType === "address" - ); - - // Find the amount input - const amountInput = inputs.find( - (input) => input.type === "pure" && input.valueType === "u64" - ); - - if (addressInput) { - to = addressInput.value; - } - - if (amountInput) { - amountMist = parseInt(amountInput.value); - } - - - if ((to === "Unknown" || amountMist === 0) && status === "success") { - // For successful transactions, check balance changes for different owner - for (const change of balanceChanges) { - if ( - change.owner?.AddressOwner && - change.owner.AddressOwner.toLowerCase() !== from.toLowerCase() - ) { - if (to === "Unknown") { - to = change.owner.AddressOwner; - } - if (amountMist === 0) { - amountMist = Math.abs(Number(change.amount || 0)); - } - break; - } + if ( + changeOwner && + changeOwner.toLowerCase() !== from.toLowerCase() && + changeAmount > maxChangeAmount + ) { + to = changeOwner; + amountRaw = changeAmount; + coinType = changeCoinType; + maxChangeAmount = changeAmount; } } - // If still no amount found, get from gas changes (for failed transactions or gas-only) - if (amountMist === 0) { - const gasChange = balanceChanges.find( + if (to === "Unknown" || amountRaw === 0) { + const inputs = tx.transaction?.data?.transaction?.inputs || []; + + // Find the address input (recipient) + const addressInput = inputs.find( + (input) => input.type === "pure" && input.valueType === "address" + ); + + // Find the amount input + const amountInput = inputs.find( + (input) => input.type === "pure" && input.valueType === "u64" + ); + + if (addressInput && to === "Unknown") { + to = addressInput.value; + } + + if (amountInput && amountRaw === 0) { + amountRaw = parseInt(amountInput.value); + } + } + + if (amountRaw === 0) { + const senderChange = balanceChanges.find( (change) => change.owner?.AddressOwner?.toLowerCase() === from.toLowerCase() ); - if (gasChange) { - const totalChange = Math.abs(Number(gasChange.amount || 0)); - const gasEstimate = 1500000; // Typical gas cost in MIST - - // Only use balance change as amount if it's significantly more than gas + if (senderChange) { + const totalChange = Math.abs(Number(senderChange.amount || 0)); + const gasEstimate = 1500000; if (totalChange > gasEstimate * 2) { - amountMist = totalChange - gasEstimate; + amountRaw = totalChange - gasEstimate; } else if (status !== "success") { - // For failed transactions, show the intended amount - amountMist = totalChange; + // For failed transactions + amountRaw = totalChange; } } } - const amountSui = (amountMist / 1e9).toFixed(6); + const datetime = tx.timestampMs ? new Date(Number(tx.timestampMs)).toLocaleString() : "N/A"; const timestamp = Number(tx.timestampMs || 0); - // Determine direction based on address - const direction = - from.toLowerCase() === address.toLowerCase() ? "Sent" : "Received"; + // Determine direction + let direction = "Other"; + + // Direct check for self-transfer (same from and to address) + if ( + from.toLowerCase() === address.toLowerCase() && + to.toLowerCase() === address.toLowerCase() + ) { + direction = "Self"; + } else { + // Check balance changes to determine if this address gained or lost value + for (const change of balanceChanges) { + if ( + change.owner?.AddressOwner?.toLowerCase() === + address.toLowerCase() + ) { + const changeAmount = Number(change.amount || 0); + if (changeAmount > 0) { + direction = "Received"; + } else if (changeAmount < 0) { + const hasPositiveChange = balanceChanges.some( + (c) => + c.owner?.AddressOwner?.toLowerCase() === + address.toLowerCase() && Number(c.amount || 0) > 0 + ); + direction = hasPositiveChange ? "Self" : "Sent"; + } + break; + } + } + + if (direction === "Other") { + if (from.toLowerCase() === address.toLowerCase()) { + direction = + to.toLowerCase() === address.toLowerCase() ? "Self" : "Sent"; + } else if (to.toLowerCase() === address.toLowerCase()) { + direction = "Received"; + } + } + } // Format status const statusText = @@ -175,11 +422,31 @@ const suiBlockchainAPI = { ? tx?.effects?.status?.error || "Transaction failed" : null; + + let decimals = 1e9; + let symbol = "SUI"; + + if (coinType) { + if (coinType.includes("usdc") || coinType.includes("USDC")) { + decimals = 1e6; + symbol = "USDC"; + } else if (coinType === "0x2::sui::SUI") { + decimals = 1e9; + symbol = "SUI"; + } + + } + + const amountFormatted = (amountRaw / decimals).toFixed(6); + return { digest: tx.digest, from, to, - amountSui, + amount: amountFormatted, + amountRaw, + coinType: coinType || "0x2::sui::SUI", + symbol: symbol, datetime, timestamp, direction, @@ -192,7 +459,12 @@ const suiBlockchainAPI = { return { txs: transactions, hasNextPage, - nextCursor, + nextCursor: { + from: fromCursor, + to: toCursor, + fromHasMore, + toHasMore, + }, }; } catch (e) { console.error("Error fetching transaction history:", e); @@ -434,9 +706,8 @@ const suiBlockchainAPI = { ); if (gasChange) { const totalChange = Math.abs(Number(gasChange.amount || 0)); - const gasEstimate = 1500000; + const gasEstimate = 1500000; - if (totalChange > gasEstimate * 2) { amount = totalChange - gasEstimate; }