diff --git a/apps/namadillo/src/App/Transactions/BundledTransactionCard.tsx b/apps/namadillo/src/App/Transactions/BundledTransactionCard.tsx new file mode 100644 index 0000000000..64dfd3cd9c --- /dev/null +++ b/apps/namadillo/src/App/Transactions/BundledTransactionCard.tsx @@ -0,0 +1,99 @@ +import { CopyToClipboardControl, Tooltip } from "@namada/components"; +import { shortenAddress } from "@namada/utils"; +import { TransactionHistory as TransactionHistoryType } from "atoms/transactions/atoms"; +import clsx from "clsx"; +import { + IoCheckmarkCircleOutline, + IoInformationCircleOutline, +} from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; +import { TransactionCard } from "./TransactionCard"; + +type Props = { + revealPkTx: TransactionHistoryType; + mainTx: TransactionHistoryType; +}; + +export const BundledTransactionCard: React.FC = ({ + revealPkTx, + mainTx, +}) => { + const publicKey = + revealPkTx.tx?.data ? JSON.parse(revealPkTx.tx.data).public_key : ""; + + return ( +
+
+
+ + + +
+
+

+ Reveal PK +
+ + + Revealing your public key registers your newly generated + Namada account on-chain so the network can associate deposits, + stake, and future signatures with a unique identity while + keeping your private key secret + +
+
+ + + Copy transaction hash + +
+

+

+ {revealPkTx.timestamp ? + new Date(revealPkTx.timestamp * 1000) + .toLocaleString("en-US", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + .replace(",", "") + : "-"} +

+
+
+ +
+

Public Key

+

+ + {shortenAddress(publicKey, 12, 12)} + + +

+
+
+ +
+ +
+
+ ); +}; diff --git a/apps/namadillo/src/App/Transactions/LocalStorageTransactionCard.tsx b/apps/namadillo/src/App/Transactions/LocalStorageTransactionCard.tsx index 16c6fd9ad4..597a5b0c62 100644 --- a/apps/namadillo/src/App/Transactions/LocalStorageTransactionCard.tsx +++ b/apps/namadillo/src/App/Transactions/LocalStorageTransactionCard.tsx @@ -1,9 +1,15 @@ import { CopyToClipboardControl, Tooltip } from "@namada/components"; import { shortenAddress } from "@namada/utils"; +import { FiatCurrency } from "App/Common/FiatCurrency"; import { TokenCurrency } from "App/Common/TokenCurrency"; import { AssetImage } from "App/Transfer/AssetImage"; import { isShieldedAddress, isTransparentAddress } from "App/Transfer/common"; +import { namadaRegistryChainAssetsMapAtom } from "atoms/integrations"; + +import { tokenPricesFamily } from "atoms/prices/atoms"; +import BigNumber from "bignumber.js"; import clsx from "clsx"; +import { useAtomValue } from "jotai"; import { FaLock } from "react-icons/fa"; import { IoCheckmarkCircleOutline, @@ -27,6 +33,25 @@ const getTitle = (transferTransaction: TransferTransactionData): string => { export const LocalStorageTransactionCard = ({ transaction, }: TransactionCardProps): JSX.Element => { + const namadaAssetsMap = useAtomValue(namadaRegistryChainAssetsMapAtom); + const namadaAsset = + namadaAssetsMap.data && + Object.values(namadaAssetsMap.data).find( + (namadaAsset) => namadaAsset.symbol === transaction.asset.symbol + ); + + // Use the Namada asset address if available, otherwise try the original asset address + const assetAddress = namadaAsset?.address || transaction.asset.address; + + const tokenPrices = useAtomValue( + tokenPricesFamily(assetAddress ? [assetAddress] : []) + ); + const tokenPrice = assetAddress && tokenPrices.data?.[assetAddress]; + + // Ensure displayAmount is a BigNumber before performing calculations + const displayAmount = BigNumber(transaction.displayAmount); + const dollarAmount = tokenPrice && displayAmount.multipliedBy(tokenPrice); + const renderKeplrIcon = (address: string): JSX.Element | null => { if (isShieldedAddress(address)) return null; if (isTransparentAddress(address)) return null; @@ -94,25 +119,22 @@ export const LocalStorageTransactionCard = ({
- +
+ + {dollarAmount && ( + + )} +
-

- From -

+

From

-

- To -

+

To

{ + if (!tx?.data) return; + let parsed; + try { + parsed = JSON.parse(tx.data); + } catch { + parsed = tx.data; + } + + return { + proposalId: parsed.id || "", + vote: parsed.vote || "", + }; +}; + const getBondOrUnbondTransactionInfo = ( tx: Tx["tx"] -): { amount: BigNumber; sender?: string; receiver?: string } | undefined => { +): TransactionInfo | undefined => { if (!tx?.data) return undefined; let parsed: BondData; try { - parsed = typeof tx.data === "string" ? JSON.parse(tx.data) : tx.data; + parsed = JSON.parse(tx.data); } catch { return undefined; } @@ -81,13 +119,33 @@ const getBondOrUnbondTransactionInfo = ( receiver: parsed.validator, }; }; + +const getRedelegationTransactionInfo = ( + tx: Tx["tx"] +): TransactionInfo | undefined => { + if (!tx?.data) return undefined; + let parsed; + try { + parsed = JSON.parse(tx.data); + } catch { + parsed = tx.data; + } + + return { + amount: BigNumber(parsed.amount), + sender: parsed.src_validator, + receiver: parsed.dest_validator, + }; +}; + const getTransactionInfo = ( tx: Tx["tx"], transparentAddress: string -): { amount: BigNumber; sender?: string; receiver?: string } | undefined => { +): TransactionInfo | undefined => { if (!tx?.data) return undefined; const parsed = typeof tx.data === "string" ? JSON.parse(tx.data) : tx.data; + const sections: RawDataSection[] = Array.isArray(parsed) ? parsed : [parsed]; const target = sections.find((s) => s.targets?.length); const source = sections.find((s) => s.sources?.length); @@ -111,126 +169,190 @@ const getTransactionInfo = ( return amount ? { amount, sender, receiver } : undefined; }; -export const TransactionCard = ({ - tx: transactionTopLevel, -}: Props): JSX.Element => { - const transaction = transactionTopLevel.tx; +// Common data for all teh card types +const useTransactionCardData = ( + tx: Tx +): { + transaction: Tx["tx"]; + asset: NamadaAsset | undefined; + transparentAddress: string; + transactionFailed: boolean; + validators: AtomWithQueryResult; +} => { + const transaction = tx.tx; const nativeToken = useAtomValue(nativeTokenAddressAtom).data; const chainAssetsMap = useAtomValue(namadaRegistryChainAssetsMapAtom); const token = getToken(transaction, nativeToken ?? ""); - const asset = token ? chainAssetsMap.data?.[token] : undefined; - const isBondingOrUnbondingTransaction = ["bond", "unbond"].includes( - transactionTopLevel?.tx?.kind ?? "" - ); const { data: accounts } = useAtomValue(allDefaultAccountsAtom); - const transparentAddress = accounts?.find((acc) => isTransparentAddress(acc.address))?.address ?? ""; - - const txnInfo = - isBondingOrUnbondingTransaction ? - getBondOrUnbondTransactionInfo(transaction) - : getTransactionInfo(transaction, transparentAddress); - const receiver = txnInfo?.receiver; - const sender = txnInfo?.sender; - const isReceived = transactionTopLevel?.kind === "received"; - const isInternalUnshield = - transactionTopLevel?.kind === "received" && isMaspAddress(sender ?? ""); const transactionFailed = transaction?.exitCode === "rejected"; const validators = useAtomValue(allValidatorsAtom); - const validator = validators?.data?.find((v) => v.address === receiver); - const getDisplayAmount = (): BigNumber => { - if (!txnInfo?.amount) { - return BigNumber(0); - } - if (!asset) { - return txnInfo.amount; - } + return { + transaction, + asset, + transparentAddress, + transactionFailed, + validators, + }; +}; - // This is a temporary hack b/c NAM amounts are mixed in nam and unam for indexer before 3.2.0 - // Whenever the migrations are run and all transactions are in micro units we need to remove this - // before 3.2.0 -> mixed - // after 3.2.0 -> unam - const guessIsDisplayAmount = (): boolean => { - // Only check Namada tokens, not other chain tokens - if (!isNamadaAsset(asset)) { - return false; - } +const getDisplayAmount = ( + txnInfo: TransactionInfo | undefined, + asset: NamadaAsset | undefined, + transactionTopLevel: Tx +): BigNumber => { + if (!txnInfo?.amount) { + return BigNumber(0); + } + if (!asset) { + return txnInfo.amount; + } - // This is a fixed flag date that most operator have already upgraded to - // indexer 3.2.0, meaning all transactions after this time are safe - const timeFlag = new Date("2025-06-18T00:00:00").getTime() / 1000; - const txTimestamp = transactionTopLevel.timestamp; - if (txTimestamp && txTimestamp > timeFlag) { - return false; - } + // This is a temporary hack b/c NAM amounts are mixed in nam and unam for indexer before 3.2.0 + // Whenever the migrations are run and all transactions are in micro units we need to remove this + // before 3.2.0 -> mixed + // after 3.2.0 -> unam + const guessIsDisplayAmount = (): boolean => { + // Only check Namada tokens, not other chain tokens + if (!isNamadaAsset(asset)) { + return false; + } - // If the amount contains the float dot, like "1.000000", it's nam - const hasFloatAmount = (): boolean => { - try { - const stringData = transactionTopLevel.tx?.data; - const objData = stringData ? JSON.parse(stringData) : {}; - return [...objData.sources, ...objData.targets].find( - ({ amount }: { amount: string }) => amount.includes(".") - ); - } catch { - return false; - } - }; - if (hasFloatAmount()) { - return true; - } + // This is a fixed flag date that most operator have already upgraded to + // indexer 3.2.0, meaning all transactions after this time are safe + const timeFlag = new Date("2025-06-18T00:00:00").getTime() / 1000; + const txTimestamp = transactionTopLevel.timestamp; + if (txTimestamp && txTimestamp > timeFlag) { + return false; + } - // if it's a huge amount, it should be unam - if (txnInfo.amount.gte(new BigNumber(1_000_000))) { + // If the amount contains the float dot, like "1.000000", it's nam + const hasFloatAmount = (): boolean => { + try { + const stringData = transactionTopLevel.tx?.data; + const objData = stringData ? JSON.parse(stringData) : {}; + return [...objData.sources, ...objData.targets].find( + ({ amount }: { amount: string }) => amount.includes(".") + ); + } catch { return false; } + }; + if (hasFloatAmount()) { + return true; + } - // if it's a small amount, it should be nam - if (txnInfo.amount.lte(new BigNumber(10))) { - return true; - } - - // if has no more hints, just accept the data as it is + // if it's a huge amount, it should be unam + if (txnInfo.amount.gte(new BigNumber(1_000_000))) { return false; - }; + } - const isAlreadyDisplayAmount = guessIsDisplayAmount(); - if (isAlreadyDisplayAmount) { - // Do not transform to display amount in case it was already saved as display amount - return txnInfo.amount; + // if it's a small amount, it should be nam + if (txnInfo.amount.lte(new BigNumber(10))) { + return true; } - return toDisplayAmount(asset, txnInfo.amount); + // if has no more hints, just accept the data as it is + return false; }; - const renderKeplrIcon = (address: string): JSX.Element | null => { - if (isShieldedAddress(address)) return null; - if (isTransparentAddress(address)) return null; - return ; - }; + const isAlreadyDisplayAmount = guessIsDisplayAmount(); + if (isAlreadyDisplayAmount) { + // Do not transform to display amount in case it was already saved as display amount + return txnInfo.amount; + } - const getTitle = (tx: Tx["tx"]): string => { - const kind = tx?.kind; + return toDisplayAmount(asset, txnInfo.amount); +}; - if (!kind) return "Unknown"; - if (isInternalUnshield) return "Unshielding Transfer"; - if (isReceived) return "Receive"; - if (kind.startsWith("ibc")) return "IBC Transfer"; - if (kind === "bond") return "Stake"; - if (kind === "unbond") return "Unstake"; - if (kind === "claimRewards") return "Claim Rewards"; - if (kind === "transparentTransfer") return "Transparent Transfer"; - if (kind === "shieldingTransfer") return "Shielding Transfer"; - if (kind === "unshieldingTransfer") return "Unshielding Transfer"; - if (kind === "shieldedTransfer") return "Shielded Transfer"; - return "Transfer"; - }; +const renderKeplrIcon = (address: string): JSX.Element | null => { + if (isShieldedAddress(address)) return null; + if (isTransparentAddress(address)) return null; + return ; +}; - const displayAmount = getDisplayAmount(); +const ValidatorTooltip = ({ + validator, + children, +}: { + validator: Validator; + children: React.ReactNode; +}): JSX.Element => { + return ( +
+ {children} + +
+
+
+ {validator.imageUrl ? + {validator.alias + :
+ {validator.alias?.charAt(0) || "?"} +
+ } +
+
+

+ {validator.alias || "Unknown Validator"} +

+
+
+ +
+

+ {shortenAddress(validator.address, 16, 16)} +

+
+ +
+
+ Commission + + {formatPercentage(validator.commission)} + +
+
+ Approximate APR (%) + + {formatPercentage(validator.expectedApr)} + +
+
+ Voting Power + + {validator.votingPowerPercentage ? + formatPercentage( + new BigNumber(validator.votingPowerPercentage) + ) + : "0%"} + +
+
+
+
+
+ ); +}; +const TransactionCardContainer = ({ + children, + hasValidatorImage = false, +}: { + children: React.ReactNode; + transactionFailed: boolean; + hasValidatorImage?: boolean; +}): JSX.Element => { return (
-
- + ); +}; + +const TransactionHeader = ({ + transactionFailed, + title, + wrapperId, + timestamp, +}: { + transactionFailed: boolean; + title: string; + wrapperId?: string; + timestamp?: number; +}): JSX.Element => { + return ( +
+ + {!transactionFailed && ( + + )} + {transactionFailed && ( + + )} + + +
+

- {!transactionFailed && ( - - )} - {transactionFailed && ( - - )} - - -
-

+ + + Copy transaction hash + +

+

+

+ {timestamp ? + new Date(timestamp * 1000) + .toLocaleString("en-US", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + hour: "2-digit", + minute: "2-digit", }) - )} - > - {transactionFailed && "Failed"} {getTitle(transaction)}{" "} -
- - - Copy transaction hash - -
-

-

- {transactionTopLevel?.timestamp ? - new Date(transactionTopLevel.timestamp * 1000) - .toLocaleString("en-US", { - day: "2-digit", - month: "2-digit", - year: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - .replace(",", "") - : "-"} -

-
+ .replace(",", "") + : "-"} +

+
+ ); +}; -
-
- -
+const TransactionAmount = ({ + asset, + amount, +}: { + asset: NamadaAsset | undefined; + amount: BigNumber; +}): JSX.Element => { + const tokenPrices = useAtomValue( + tokenPricesFamily(asset?.address ? [asset.address] : []) + ); + const tokenPrice = asset?.address && tokenPrices.data?.[asset.address]; + const dollarAmount = tokenPrice && amount.multipliedBy(tokenPrice); + + return ( +
+
+ +
+
+ {dollarAmount && ( + + )}
+
+ ); +}; - {!isBondingOrUnbondingTransaction && ( -
-

- From -

-

- {isShieldedAddress(sender ?? "") ? - - Shielded - - :
- {renderKeplrIcon(sender ?? "")} - {shortenAddress(sender ?? "", 10, 10)} +const BondUnbondTransactionCard = ({ tx }: Props): JSX.Element => { + const { transaction, asset, transactionFailed, validators } = + useTransactionCardData(tx); + const txnInfo = getBondOrUnbondTransactionInfo(transaction); + const displayAmount = getDisplayAmount(txnInfo, asset, tx); + const validator = validators?.data?.find( + (v) => v.address === txnInfo?.receiver + ); + + const getTitle = useCallback((): string => { + if (transaction?.kind === "bond") return "Stake"; + if (transaction?.kind === "unbond") return "Unstake"; + return "Bond/Unbond"; + }, [transaction?.kind]); + + return ( + + + +
+

+ {transaction?.kind === "bond" ? "To Validator" : "From Validator"} +

+

+ {validator?.imageUrl ? + +
+ {validator?.alias} +
- } -

-
- )} + + : shortenAddress(txnInfo?.receiver ?? "", 10, 10)} +

+
+ + ); +}; + +const RedelegationTransactionCard = ({ tx }: Props): JSX.Element => { + const { transaction, asset, transactionFailed, validators } = + useTransactionCardData(tx); + const txnInfo = getRedelegationTransactionInfo(transaction); + const displayAmount = getDisplayAmount(txnInfo, asset, tx); + const redelegationSource = validators?.data?.find( + (v) => v.address === txnInfo?.sender + ); + const redelegationTarget = validators?.data?.find( + (v) => v.address === txnInfo?.receiver + ); + + return ( + + + +
-

- {isBondingOrUnbondingTransaction ? "Validator" : "To"} +

From Validator

+

+ {redelegationSource?.imageUrl ? + +
+ + {redelegationSource?.alias} + + +
+
+ : shortenAddress(txnInfo?.sender ?? "", 10, 10)} +

+
+ +
+

To Validator

+

+ {redelegationTarget?.imageUrl ? + +
+ + {redelegationTarget?.alias} + + +
+
+ : shortenAddress(txnInfo?.receiver ?? "", 10, 10)} +

+
+
+ ); +}; + +const VoteTransactionCard = ({ tx }: Props): JSX.Element => { + const { transaction, transactionFailed } = useTransactionCardData(tx); + const voteInfo = getVoteTransactionInfo(transaction); + const proposalId = voteInfo?.proposalId && BigInt(voteInfo.proposalId); + const proposal = useAtomValue(proposalFamily(proposalId || BigInt(0))); + const navigate = useNavigate(); + + const proposalTitle = + proposal.data?.content.title ? + `#${voteInfo?.proposalId} - ${proposal.data.content.title.slice(0, 20)}...` + : `#${voteInfo?.proposalId}`; + const proposer = proposal.data?.author; + return ( + + +
+

Vote

+

+ {voteInfo?.vote ?? "-"} +

+
+
+

Proposal

+

+
+ + {proposal.data?.content.title && ( + + {`#${voteInfo?.proposalId} - ${proposal.data.content.title}`} + + )} +

+
+
+

Proposer

+

{proposer ? shortenAddress(proposer, 10, 10) : "-"}

+
+
+ ); +}; + +const WithdrawTransactionCard = ({ tx }: Props): JSX.Element => { + const { transaction, transactionFailed, validators } = + useTransactionCardData(tx); + + const txnInfo = JSON.parse(tx.tx?.data ?? "{}"); + const validator = validators?.data?.find( + (v) => v.address === txnInfo?.validator + ); + + return ( + + +
+

From Validator

+

+ {validator?.imageUrl ? + +
+ {" "} + {validator?.alias} +
+
+ : shortenAddress(txnInfo?.validator ?? "", 10, 10)} +

+
+
+ ); +}; + +const GeneralTransactionCard = ({ tx }: Props): JSX.Element => { + const { transaction, asset, transparentAddress, transactionFailed } = + useTransactionCardData(tx); + const txnInfo = getTransactionInfo(transaction, transparentAddress); + const displayAmount = getDisplayAmount(txnInfo, asset, tx); + const receiver = txnInfo?.receiver; + const sender = txnInfo?.sender; + const isReceived = tx?.kind === "received"; + const isInternalUnshield = + tx?.kind === "received" && isMaspAddress(sender ?? ""); + + const getTitle = (): string => { + const kind = transaction?.kind; + + if (!kind) return "Unknown"; + if (isInternalUnshield) return "Unshielding Transfer"; + if (isReceived) return "Receive"; + if (kind.startsWith("ibc")) return "IBC Transfer"; + if (kind === "claimRewards") return "Claim Rewards"; + if (kind === "transparentTransfer") return "Transparent Transfer"; + if (kind === "shieldingTransfer") return "Shielding Transfer"; + if (kind === "unshieldingTransfer") return "Unshielding Transfer"; + if (kind === "shieldedTransfer") return "Shielded Transfer"; + return "Transfer"; + }; + + return ( + + + + + +
+

From

+

+ {isShieldedAddress(sender ?? "") ? + + Shielded + + :
+ {renderKeplrIcon(sender ?? "")} + {shortenAddress(sender ?? "", 10, 10)} +
+ } +

+
+ +
+

To

{isShieldedAddress(receiver ?? "") ? Shielded - : isBondingOrUnbondingTransaction ? - validator?.imageUrl ? - - : shortenAddress(receiver ?? "", 10, 10) :
{renderKeplrIcon(receiver ?? "")} {shortenAddress(receiver ?? "", 10, 10)} @@ -350,6 +754,28 @@ export const TransactionCard = ({ }

- +
); }; + +export const TransactionCard = ({ tx }: Props): JSX.Element => { + const transactionKind = tx.tx?.kind; + + if (transactionKind === "bond" || transactionKind === "unbond") { + return ; + } + + if (transactionKind === "redelegation") { + return ; + } + + if (transactionKind === "voteProposal") { + return ; + } + + if (transactionKind === "withdraw") { + return ; + } + + return ; +}; diff --git a/apps/namadillo/src/App/Transactions/TransactionHistory.tsx b/apps/namadillo/src/App/Transactions/TransactionHistory.tsx index e21bbe4802..2279c9b279 100644 --- a/apps/namadillo/src/App/Transactions/TransactionHistory.tsx +++ b/apps/namadillo/src/App/Transactions/TransactionHistory.tsx @@ -13,6 +13,7 @@ import { useAtomValue } from "jotai"; import { useCallback, useMemo, useState } from "react"; import { twMerge } from "tailwind-merge"; import { TransferTransactionData } from "types"; +import { BundledTransactionCard } from "./BundledTransactionCard"; import { LocalStorageTransactionCard } from "./LocalStorageTransactionCard"; import { PendingTransactionCard } from "./PendingTransactionCard"; import { TransactionCard } from "./TransactionCard"; @@ -27,11 +28,28 @@ export const transferKindOptions = [ "ibcShieldingTransfer", "ibcUnshieldingTransfer", "ibcShieldedTransfer", + "redelegation", + "voteProposal", + "withdraw", "bond", "unbond", + "revealPk", "received", ]; +type BundledTransaction = + | { + type: "bundled"; + revealPkTx: TransactionHistoryType; + mainTx: TransactionHistoryType; + timestamp: number; + } + | { + type: "single"; + tx: TransactionHistoryType; + timestamp: number; + }; + export const TransactionHistory = (): JSX.Element => { const [currentPage, setCurrentPage] = useState(0); const [filter, setFilter] = useState("All"); @@ -61,26 +79,32 @@ export const TransactionHistory = (): JSX.Element => { timestamp: new Date(transaction.updatedAt).getTime() / 1000, })); - const handleFiltering = useCallback( - (transaction: TransactionHistoryType): boolean => { - const transactionKind = transaction.tx?.kind ?? ""; - if (filter.toLowerCase() === "all") { + const handleFiltering = (transaction: TransactionHistoryType): boolean => { + const transactionKind = transaction.tx?.kind ?? ""; + switch (filter.toLowerCase()) { + case "all": return transferKindOptions.includes(transactionKind); - } else if (filter === "received") { + case "received": return transaction.kind === "received"; - } else if (filter === "transfer") { + case "redelegation": + return transactionKind === "redelegation"; + case "vote": + return transactionKind === "voteProposal"; + case "withdraw": + return transactionKind === "withdraw"; + case "transfer": return [ "transparentTransfer", "shieldingTransfer", "unshieldingTransfer", "shieldedTransfer", ].includes(transactionKind); - } else if (filter === "ibc") { + case "ibc": return transactionKind.startsWith("ibc"); - } else return transactionKind === filter; - }, - [filter] - ); + default: + return transactionKind === filter; + } + }; const JSONstringifyOrder = useCallback((obj: unknown): string => { const allKeys = new Set(); @@ -121,12 +145,53 @@ export const TransactionHistory = (): JSX.Element => { }, [] ); - // Only show historical transactions that are in the transferKindOptions array + + // Bundle reveal PK transactions with the previous transaction + const bundleTransactions = ( + transactions: (TransactionHistoryType | TransferTransactionData)[] + ): BundledTransaction[] => { + const bundled: BundledTransaction[] = []; + let i = 0; + + while (i < transactions.length) { + const currentTx = transactions[i]; + + // Check if next transaction is revealPk that should be bundled with current + const nextTx = transactions[i + 1]; + if ( + i + 1 < transactions.length && + "tx" in nextTx && + nextTx.tx?.kind === "revealPk" + ) { + bundled.push({ + type: "bundled", + revealPkTx: nextTx as TransactionHistoryType, + mainTx: currentTx as TransactionHistoryType, + timestamp: currentTx.timestamp || 0, + }); + i += 2; // Skip both transactions + } else if ("tx" in currentTx && currentTx.tx?.kind === "revealPk") { + // Skip standalone revealPk transactions + i += 1; + } else { + // Add as single transaction + bundled.push({ + type: "single", + tx: currentTx as TransactionHistoryType, + timestamp: currentTx.timestamp || 0, + }); + i += 1; + } + } + + return bundled; + }; + const filteredTransactions = transactions?.results?.filter((transaction) => handleFiltering(transaction) ) ?? []; - // Remove duplicates + const historicalTransactions = filterDuplicateTransactions(filteredTransactions); @@ -135,30 +200,44 @@ export const TransactionHistory = (): JSX.Element => { ...completedIbcShieldTransactions, ].sort((a, b) => (b?.timestamp ?? 0) - (a?.timestamp ?? 0)); - // Calculate total pages based on the filtered transactions + // Bundle transactions after sorting + const bundledTransactions = bundleTransactions(allHistoricalTransactions); + + // Calculate total pages based on bundled transactions const totalPages = Math.max( 1, - Math.ceil(allHistoricalTransactions.length / ITEMS_PER_PAGE) + Math.ceil(bundledTransactions.length / ITEMS_PER_PAGE) ); const paginatedTransactions = useMemo(() => { const startIndex = currentPage * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; - return allHistoricalTransactions.slice(startIndex, endIndex); - }, [allHistoricalTransactions, currentPage]); + return bundledTransactions.slice(startIndex, endIndex); + }, [bundledTransactions, currentPage]); const renderRow = ( - transaction: TransactionHistoryType, + transaction: BundledTransaction, index: number ): TableRow => { + const key = + transaction.type === "bundled" ? + `${transaction.revealPkTx.tx?.txId}-${transaction.mainTx.tx?.txId}` + : transaction.tx.tx?.txId || index.toString(); + return { - key: transaction.tx?.txId || index.toString(), + key, cells: [ - transaction?.tx ? - + transaction.type === "bundled" ? + + : transaction.tx?.tx ? + : , ], }; @@ -211,7 +290,7 @@ export const TransactionHistory = (): JSX.Element => { arrowContainerProps={{ className: "right-4" }} listContainerProps={{ className: - "w-[200px] mt-2 border border-white left-0 transform-none", + "w-[200px] mt-2 border border-white left-0 transform-none max-h-[200px] overflow-y-auto dark-scrollbar", }} listItemProps={{ className: "text-sm border-0 py-0 [&_label]:py-1.5", @@ -246,6 +325,21 @@ export const TransactionHistory = (): JSX.Element => { value: "Unstake", ariaLabel: "Unstake", }, + { + id: "redelegation", + value: "Redelegate", + ariaLabel: "Redelegate", + }, + { + id: "voteProposal", + value: "Vote", + ariaLabel: "Vote", + }, + { + id: "withdraw", + value: "Withdraw", + ariaLabel: "Withdraw", + }, ]} />
@@ -274,9 +368,7 @@ export const TransactionHistory = (): JSX.Element => { id="transactions-table" headers={[{ children: " ", className: "w-full" }]} renderRow={renderRow} - itemList={ - paginatedTransactions as unknown as TransactionHistoryType[] - } + itemList={paginatedTransactions} page={currentPage} pageCount={totalPages} onPageChange={handlePageChange}