diff --git a/app/history/page.tsx b/app/history/page.tsx index 239a4315..04c47f41 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -62,6 +62,44 @@ import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.share const ITEMS_PER_PAGE = 5; +/** + * Check if two transfers are the same by comparing their unique identifiers. + * For V2 transfers, the backend returns database-generated IDs, while locally + * created transfers use messageId/txHash as ID. This function matches by + * transaction hash or extrinsic hash to handle this case. + */ +const isSameTransfer = (t1: Transfer, t2: Transfer): boolean => { + // Match by ID if both have non-empty IDs + if (t1.id === t2.id && t1.id.length > 0) return true; + + // For ToPolkadotTransferResult (E->P), match by transaction hash + if (t1.sourceType === "ethereum" && t2.sourceType === "ethereum") { + const t1Casted = t1 as historyV2.ToPolkadotTransferResult; + const t2Casted = t2 as historyV2.ToPolkadotTransferResult; + if ( + t1Casted.submitted.transactionHash === + t2Casted.submitted.transactionHash && + t1Casted.submitted.transactionHash.length > 0 + ) { + return true; + } + } + + // For ToEthereumTransferResult (P->E), match by extrinsic hash + if (t1.sourceType === "substrate" && t2.sourceType === "substrate") { + const t1Casted = t1 as historyV2.ToEthereumTransferResult; + const t2Casted = t2 as historyV2.ToEthereumTransferResult; + if ( + t1Casted.submitted.extrinsic_hash === t2Casted.submitted.extrinsic_hash && + t1Casted.submitted.extrinsic_hash.length > 0 + ) { + return true; + } + } + + return false; +}; + const getExplorerLinks = ( transfer: Transfer, source: assetsV2.TransferLocation, @@ -451,9 +489,8 @@ export default function History() { const oldTransferCutoff = new Date().getTime() - 4 * 60 * 60 * 1000; // 4 hours for (let i = 0; i < transfersPendingLocal.length; ++i) { if ( - transferHistoryCache.find( - (h) => - h.id?.toLowerCase() === transfersPendingLocal[i].id?.toLowerCase(), + transferHistoryCache.find((h) => + isSameTransfer(h, transfersPendingLocal[i]), ) || new Date(transfersPendingLocal[i].info.when).getTime() < oldTransferCutoff @@ -479,7 +516,13 @@ export default function History() { ]); const allTransfers: Transfer[] = []; for (const pending of transfersPendingLocal) { - if (transferHistoryCache.find((t) => t.id === pending.id)) { + // Check if this pending transfer already exists in the cache + // Match by ID, transaction hash, or message ID to handle V2 transfers + const isDuplicate = transferHistoryCache.find((t) => + isSameTransfer(t, pending), + ); + + if (isDuplicate) { continue; } allTransfers.push(pending); diff --git a/app/txcomplete/page.tsx b/app/txcomplete/page.tsx index 476124ca..2459c7d1 100644 --- a/app/txcomplete/page.tsx +++ b/app/txcomplete/page.tsx @@ -17,7 +17,7 @@ import { Transfer } from "@/store/transferHistory"; import base64url from "base64url"; import { LucideLoaderCircle } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { Suspense, useContext, useMemo } from "react"; +import { Suspense, useContext, useMemo, useState } from "react"; import { TransferStatusBadge } from "@/components/history/TransferStatusBadge"; import { Button } from "@/components/ui/button"; import Link from "next/link"; @@ -38,6 +38,7 @@ import { useAtom, useAtomValue } from "jotai"; import { walletTxChecker } from "@/utils/addresses"; import { NeuroWebUnwrapForm } from "@/components/transfer/NeuroWebUnwrapStep"; import { ethereumAccountAtom, ethereumAccountsAtom } from "@/store/ethereum"; +import { AddTipDialog } from "@/components/AddTipDialog"; const Loading = () => { return ( @@ -58,6 +59,11 @@ function TxCard(props: TxCardProps) { const { transfer, refresh, inHistory, registry } = props; const { destination } = getEnvDetail(transfer, registry); const links: { name: string; link: string }[] = []; + const [tipDialogOpen, setTipDialogOpen] = useState(false); + const [tipParams, setTipParams] = useState<{ + direction: "Inbound" | "Outbound"; + nonce: number; + } | null>(null); const token = registry.ethereumChains[registry.ethChainId].assets[ @@ -199,6 +205,51 @@ function TxCard(props: TxCardProps) { {neuroWeb} +
+ + {tipParams && ( + + )} +
void; + direction: "Inbound" | "Outbound"; + nonce: number; +}; + +export const AddTipDialog: FC = ({ + open, + onOpenChange, + direction, + nonce, +}) => { + const [tipAsset, setTipAsset] = useState<"DOT" | "ETH">("DOT"); + const [tipAmount, setTipAmount] = useState("0.00"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState<{ + blockHash: string; + txHash: string; + } | null>(null); + const [estimatedFee, setEstimatedFee] = useState(null); + + const [polkadotAccount, setPolkadotAccount] = useAtom(polkadotAccountAtom); + const polkadotAccounts = useAtomValue(polkadotAccountsAtom); + const setPolkadotWalletModalOpen = useSetAtom(polkadotWalletModalOpenAtom); + const registry = useContext(RegistryContext)!; + const environment = getEnvironment(); + const [selectedAccountAddress, setSelectedAccountAddress] = useState< + string | null + >(null); + + const hasWallet = polkadotAccounts && polkadotAccounts.length > 0; + const needsAccountSelection = hasWallet && !polkadotAccount; + + const accountToUse = + selectedAccountAddress && polkadotAccounts + ? polkadotAccounts.find((acc) => acc.address === selectedAccountAddress) + : polkadotAccount; + + useEffect(() => { + if (open) { + setError(null); + setSuccess(null); + setEstimatedFee(null); + if (polkadotAccount) { + setSelectedAccountAddress(polkadotAccount.address); + } else if (polkadotAccounts && polkadotAccounts.length > 0) { + setSelectedAccountAddress(polkadotAccounts[0].address); + } + } + }, [open, polkadotAccount, polkadotAccounts]); + + useEffect(() => { + if (!open || !accountToUse) return; + + const estimateFee = async () => { + try { + const wsProvider = new WsProvider( + environment.config.PARACHAINS[ + environment.config.ASSET_HUB_PARAID.toString() + ], + ); + const api = await ApiPromise.create({ provider: wsProvider }); + + const tipAmountBigInt = BigInt( + Math.floor(parseFloat(tipAmount || "0") * 1e10), + ); + + const fee = await addTip.getFee( + api, + registry, + { + direction, + nonce: BigInt(nonce), + tipAsset, + tipAmount: tipAmountBigInt, + }, + accountToUse.address, + ); + + setEstimatedFee((Number(fee) / 1e10).toFixed(4)); + await api.disconnect(); + } catch (err) { + console.error("Fee estimation failed:", err); + setEstimatedFee(null); + } + }; + + estimateFee(); + }, [open]); + + const handleSubmit = async () => { + if (!hasWallet) { + setPolkadotWalletModalOpen(true); + return; + } + + if (!accountToUse) { + setError("Please select a Polkadot account"); + return; + } + + setBusy(true); + setError(null); + setSuccess(null); + + try { + const wsProvider = new WsProvider( + environment.config.PARACHAINS[ + environment.config.ASSET_HUB_PARAID.toString() + ], + ); + const api = await ApiPromise.create({ provider: wsProvider }); + + const tipAmountBigInt = BigInt(Math.floor(parseFloat(tipAmount) * 1e10)); + + const tipResult = await addTip.createAddTip(api, registry, { + direction, + nonce: BigInt(nonce), + tipAsset, + tipAmount: tipAmountBigInt, + }); + + if (!accountToUse.signer) { + throw new Error("No signer available from wallet"); + } + + const response = await addTip.signAndSend( + api, + tipResult, + accountToUse.address, + { signer: accountToUse.signer }, + ); + + setSuccess(response); + await api.disconnect(); + } catch (err: any) { + console.error("Add tip failed:", err); + setError(err?.message || "Failed to add tip"); + } finally { + setBusy(false); + } + }; + + const handleClose = () => { + if (!busy) { + onOpenChange(false); + } + }; + + return ( + + + + Add Tip + + Add a tip to make this message profitable for relayers to relay this + message (nonce: {nonce}). + + + + {!success ? ( +
+ {hasWallet && polkadotAccounts && polkadotAccounts.length > 0 && ( +
+ + +
+ )} + +
+
+
+ + setTipAmount(e.target.value)} + disabled={busy} + placeholder="Enter tip amount" + className="text-left" + /> +
+
+
+
+ + +
+
+
+ + {estimatedFee && ( +
+ Estimated fee: ~{estimatedFee} DOT +
+ )} + + {!hasWallet && ( +
+ Please connect your Polkadot wallet to continue +
+ )} + + {error &&
{error}
} +
+ ) : ( +
+
+

+ Tip added successfully! +

+

+ Block Hash:{" "} + {success.blockHash} +

+

+ Transaction Hash:{" "} + + {success.txHash} + +

+
+
+ )} + + + {!success ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +}; diff --git a/components/SelectedPolkadotAccount.tsx b/components/SelectedPolkadotAccount.tsx index 12db098b..fe859527 100644 --- a/components/SelectedPolkadotAccount.tsx +++ b/components/SelectedPolkadotAccount.tsx @@ -19,6 +19,7 @@ type SelectedPolkadotAccountProps = { polkadotAccount?: string; onValueChange?: (address: string) => void; placeholder?: string; + disabled?: boolean; }; export const SelectedPolkadotAccount: FC = ({ @@ -28,13 +29,15 @@ export const SelectedPolkadotAccount: FC = ({ polkadotAccounts, polkadotAccount, placeholder, + disabled, }) => { return (