refactor transaction history retrieval and enhance transaction detail formatting

This commit is contained in:
void-57 2025-11-09 05:22:20 +05:30
parent 897aacf04e
commit 2934f06846
3 changed files with 386 additions and 121 deletions

View File

@ -1088,11 +1088,6 @@
if (privateKey && recipientAddress && validators.isPrivateKey(privateKey) && validators.isSuiAddress(recipientAddress)) { if (privateKey && recipientAddress && validators.isPrivateKey(privateKey) && validators.isSuiAddress(recipientAddress)) {
try { try {
const wallet = await suiCrypto.generateMultiChain(privateKey); 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) { } catch (error) {
console.error('Error validating addresses:', error); console.error('Error validating addresses:', error);
} }
@ -1625,6 +1620,8 @@
currentAddress = address; currentAddress = address;
currentPage = 1; currentPage = 1;
// Clear the cache when searching for a new address
cache = {};
// Get balance and display // Get balance and display
const balance = await suiBlockchainAPI.getBalance(address, '0x2::sui::SUI'); const balance = await suiBlockchainAPI.getBalance(address, '0x2::sui::SUI');
@ -1689,19 +1686,13 @@
resultsDiv.innerHTML = '<div class="transaction-loading"><div class="loading-container"><div class="loading-spinner"><i class="fas"></i></div><div class="loading-text">Loading transactions...</div></div></div>'; resultsDiv.innerHTML = '<div class="transaction-loading"><div class="loading-container"><div class="loading-spinner"><i class="fas"></i></div><div class="loading-text">Loading transactions...</div></div></div>';
let useCursor = cursor;
if (page > 1 && cache[page - 1]) {
useCursor = cache[page - 1].nextCursor;
}
try { try {
const result = await suiBlockchainAPI.getTransactionHistory(currentAddress, useCursor, 10); const result = await suiBlockchainAPI.getTransactionHistory(currentAddress, page, 10);
const hasNextPage = result.hasNextPage; const hasNextPage = result.hasNextPage;
const nextCursor = result.nextCursor;
// Store in cache // Store in cache
cache[page] = { details: result.txs, hasNextPage, nextCursor }; cache[page] = { details: result.txs, hasNextPage, nextCursor: null };
// Render transactions // Render transactions
renderTransactionPage(cache[page]); renderTransactionPage(cache[page]);
@ -1753,11 +1744,11 @@
<div class="tx-top-row"> <div class="tx-top-row">
<div class="tx-left"> <div class="tx-left">
<div class="tx-icon"> <div class="tx-icon">
<i class="fas fa-${tx.direction === 'Sent' ? 'arrow-up' : 'arrow-down'}"></i> <i class="fas fa-${tx.direction === 'Self' ? 'exchange-alt' : tx.direction === 'Sent' ? 'arrow-up' : 'arrow-down'}"></i>
</div> </div>
<div class="tx-main-info"> <div class="tx-main-info">
<div class="tx-direction-label">${tx.direction}</div> <div class="tx-direction-label">${tx.direction === 'Self' ? 'Self Transfer' : tx.direction}</div>
<div class="tx-amount ${tx.direction.toLowerCase()}">${tx.direction === 'Sent' ? '-' : '+'}${tx.amountSui} SUI</div> <div class="tx-amount ${tx.direction.toLowerCase()}">${tx.direction === 'Sent' ? '-' : tx.direction === 'Received' ? '+' : ''}${tx.amount} ${tx.symbol}</div>
</div> </div>
</div> </div>
<div class="tx-meta"> <div class="tx-meta">

View File

@ -566,7 +566,7 @@ body {
.main-content { .main-content {
flex: 1; flex: 1;
padding: 2rem; padding: 2rem;
padding-bottom: 140px; /* Space for mobile navigation */ padding-bottom: 140px;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow-x: hidden; overflow-x: hidden;
@ -1468,6 +1468,9 @@ body {
color: #10b981; color: #10b981;
} }
.tx-amount.self {
color: #8b5cf6;
}
.tx-details { .tx-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1986,7 +1989,7 @@ body {
} }
.tx-icon { .tx-icon {
display: none; /* Hide icon on mobile to save space */ display: none;
} }
/* Pagination Mobile */ /* Pagination Mobile */

View File

@ -15,72 +15,284 @@ const suiBlockchainAPI = {
return json?.result?.totalBalance || 0; return json?.result?.totalBalance || 0;
}, },
// Get Transaction History // 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"; const SUI_RPC_URL = "https://fullnode.mainnet.sui.io:443";
try { try {
//Query transaction digests using ToAddress filter const requiredTransactions = page * limit;
const res = await fetch(SUI_RPC_URL, { const smartBatchSize = requiredTransactions + 20;
method: "POST", console.log(
headers: { "Content-Type": "application/json" }, `Page ${page}: Smart batching - need ${requiredTransactions}, fetching ${smartBatchSize}`
body: JSON.stringify({ );
jsonrpc: "2.0",
id: 1,
method: "suix_queryTransactionBlocks",
params: [
{
filter: { ToAddress: address },
},
cursor,
limit,
true,
],
}),
});
const json = await res.json(); // Get both sent (FromAddress) and received (ToAddress) transactions
if (json.error) throw new Error(json.error.message); 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) || []; // Keep a set of unique digests while fetching.
const nextCursor = json.result?.nextCursor || null; const uniqueDigestSet = new Set();
const hasNextPage = !!json.result?.hasNextPage; const digestToTimestamp = new Map(); // Store timestamps from queryTransactionBlocks
let safetyCounter = 0;
const MAX_FETCH_ROUNDS = 10;
//Fetch detailed information for each transaction while (
const details = await Promise.all( (fromHasMore || toHasMore) &&
digests.map(async (digest) => { uniqueDigestSet.size < smartBatchSize &&
try { safetyCounter < MAX_FETCH_ROUNDS
const detailRes = await fetch(SUI_RPC_URL, { ) {
safetyCounter++;
const requests = [];
if (fromHasMore) {
requests.push(
fetch(SUI_RPC_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
jsonrpc: "2.0", jsonrpc: "2.0",
id: 1, id: 1,
method: "sui_getTransactionBlock", method: "suix_queryTransactionBlocks",
params: [ params: [
digest,
{ {
showInput: true, filter: { FromAddress: address },
showEffects: true, options: { showInput: true, showEffects: true },
showBalanceChanges: true,
showEvents: 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); // Fetch TO transactions if we still have more
return null; 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 let digests = uniqueDigests;
const validDetails = details.filter(Boolean); console.log(
`Page ${page}: Got ${digests.length} unique digests from API`
);
// Sort by timestamp descending (newest first) const neededForPage = page * limit;
validDetails.sort((a, b) => (b.timestampMs || 0) - (a.timestampMs || 0));
// 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 // Transform to the expected format
const transactions = validDetails.map((tx) => { const transactions = validDetails.map((tx) => {
@ -88,78 +300,113 @@ const suiBlockchainAPI = {
const balanceChanges = tx.balanceChanges || []; const balanceChanges = tx.balanceChanges || [];
const status = tx.effects?.status?.status || "unknown"; const status = tx.effects?.status?.status || "unknown";
// Find recipient and amount from transaction inputs first
let to = "Unknown"; 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) if (
const addressInput = inputs.find( changeOwner &&
(input) => input.type === "pure" && input.valueType === "address" changeOwner.toLowerCase() !== from.toLowerCase() &&
); changeAmount > maxChangeAmount
) {
// Find the amount input to = changeOwner;
const amountInput = inputs.find( amountRaw = changeAmount;
(input) => input.type === "pure" && input.valueType === "u64" coinType = changeCoinType;
); maxChangeAmount = changeAmount;
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 still no amount found, get from gas changes (for failed transactions or gas-only) if (to === "Unknown" || amountRaw === 0) {
if (amountMist === 0) { const inputs = tx.transaction?.data?.transaction?.inputs || [];
const gasChange = balanceChanges.find(
// 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) =>
change.owner?.AddressOwner?.toLowerCase() === from.toLowerCase() change.owner?.AddressOwner?.toLowerCase() === from.toLowerCase()
); );
if (gasChange) { if (senderChange) {
const totalChange = Math.abs(Number(gasChange.amount || 0)); const totalChange = Math.abs(Number(senderChange.amount || 0));
const gasEstimate = 1500000; // Typical gas cost in MIST const gasEstimate = 1500000;
// Only use balance change as amount if it's significantly more than gas
if (totalChange > gasEstimate * 2) { if (totalChange > gasEstimate * 2) {
amountMist = totalChange - gasEstimate; amountRaw = totalChange - gasEstimate;
} else if (status !== "success") { } else if (status !== "success") {
// For failed transactions, show the intended amount // For failed transactions
amountMist = totalChange; amountRaw = totalChange;
} }
} }
} }
const amountSui = (amountMist / 1e9).toFixed(6);
const datetime = tx.timestampMs const datetime = tx.timestampMs
? new Date(Number(tx.timestampMs)).toLocaleString() ? new Date(Number(tx.timestampMs)).toLocaleString()
: "N/A"; : "N/A";
const timestamp = Number(tx.timestampMs || 0); const timestamp = Number(tx.timestampMs || 0);
// Determine direction based on address // Determine direction
const direction = let direction = "Other";
from.toLowerCase() === address.toLowerCase() ? "Sent" : "Received";
// 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 // Format status
const statusText = const statusText =
@ -175,11 +422,31 @@ const suiBlockchainAPI = {
? tx?.effects?.status?.error || "Transaction failed" ? tx?.effects?.status?.error || "Transaction failed"
: null; : 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 { return {
digest: tx.digest, digest: tx.digest,
from, from,
to, to,
amountSui, amount: amountFormatted,
amountRaw,
coinType: coinType || "0x2::sui::SUI",
symbol: symbol,
datetime, datetime,
timestamp, timestamp,
direction, direction,
@ -192,7 +459,12 @@ const suiBlockchainAPI = {
return { return {
txs: transactions, txs: transactions,
hasNextPage, hasNextPage,
nextCursor, nextCursor: {
from: fromCursor,
to: toCursor,
fromHasMore,
toHasMore,
},
}; };
} catch (e) { } catch (e) {
console.error("Error fetching transaction history:", e); console.error("Error fetching transaction history:", e);
@ -436,7 +708,6 @@ const suiBlockchainAPI = {
const totalChange = Math.abs(Number(gasChange.amount || 0)); const totalChange = Math.abs(Number(gasChange.amount || 0));
const gasEstimate = 1500000; const gasEstimate = 1500000;
if (totalChange > gasEstimate * 2) { if (totalChange > gasEstimate * 2) {
amount = totalChange - gasEstimate; amount = totalChange - gasEstimate;
} }