diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 32f0ecd8..7693f5b1 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -7,7 +7,12 @@ import { ContextApp } from "../../context/ContextAppState"; import routes from "../../constants/routes.json"; import TorIndicator from "./TorIndicator"; -import { SyncStatusScanRangePriorityEnum, SyncStatusScanRangeType, ValueTransferClass } from "../appstate"; +import { + SyncStatusScanRangePriorityEnum, + SyncStatusScanRangeType, + ValueTransferClass, + ValueTransferStatusEnum, +} from "../appstate"; import ScrollPaneTop from "../scrollPane/ScrollPane"; import DetailLine from "../detailLine/DetailLine"; import { useNavigate } from "react-router-dom"; @@ -44,9 +49,12 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { useEffect(() => { // set somePending as well here when I know there is something new in ValueTransfers + // avoid failed txs because always they have 0 confirmations. const pending: number = valueTransfers.length > 0 - ? valueTransfers.filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length + ? valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length : 0; setAnyPending(pending > 0); }, [valueTransfers]); @@ -340,6 +348,7 @@ const Dashboard: React.FC = ({ navigateToHistory }) => { key={index} label={Utils.VTTypeWithConfirmations(vt.type, vt.status, vt.confirmations)} value={"ZEC " + Utils.maxPrecisionTrimmed(vt.amount)} + failed={vt.status === ValueTransferStatusEnum.failed} /> ))} diff --git a/src/components/detailLine/DetailLine.test.tsx b/src/components/detailLine/DetailLine.test.tsx index fc3240a6..d34170a2 100644 --- a/src/components/detailLine/DetailLine.test.tsx +++ b/src/components/detailLine/DetailLine.test.tsx @@ -2,6 +2,11 @@ import React from "react"; import { render, screen } from "../../test-utils"; import DetailLine from "./DetailLine"; +// DetailLine pulls Utils → electronBridge, which reads window.electronAPI at +// module load. That global only exists in the Electron preload, so jsdom +// would throw at import time without this mock. +jest.mock("../../electronBridge"); + describe("DetailLine", () => { it("renders without crashing", () => { render(); diff --git a/src/components/detailLine/DetailLine.tsx b/src/components/detailLine/DetailLine.tsx index 95bd8b7c..c86a506e 100644 --- a/src/components/detailLine/DetailLine.tsx +++ b/src/components/detailLine/DetailLine.tsx @@ -1,6 +1,7 @@ import React from "react"; import cstyles from "../common/Common.module.css"; import styles from "./DetailLine.module.css"; +import Utils from "../../utils/utils"; type DetailLineProps = { label: string; @@ -8,13 +9,16 @@ type DetailLineProps = { // (e.g. a Tor onion next to the ZEC Price line). String values still work // unchanged because string is assignable to ReactNode. value: React.ReactNode; + failed?: boolean; }; -const DetailLine = ({ label, value }: DetailLineProps) => { +const DetailLine = ({ label, value, failed }: DetailLineProps) => { return (
{label} :
-
{value}
+
+ {value} +
); }; diff --git a/src/components/history/History.tsx b/src/components/history/History.tsx index 4df342f6..3c6e67ef 100644 --- a/src/components/history/History.tsx +++ b/src/components/history/History.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; import cstyles from "../common/Common.module.css"; import styles from "./History.module.css"; -import { ValueTransferClass, AddressBookEntryClass } from "../appstate"; +import { ValueTransferClass, AddressBookEntryClass, ValueTransferStatusEnum } from "../appstate"; import ScrollPaneTop from "../scrollPane/ScrollPane"; import VtItemBlock from "./components/VtItemBlock"; import VtModal from "./components/VtModal"; @@ -43,7 +43,9 @@ const History: React.FC = () => { // set somePending as well here when I know there is something new in ValueTransfers const pending: number = valueTransfers.length > 0 - ? valueTransfers.filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length + ? valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length : 0; setAnyPending(pending > 0); }, [valueTransfers]); diff --git a/src/components/messages/Messages.tsx b/src/components/messages/Messages.tsx index 2883ea44..24700253 100644 --- a/src/components/messages/Messages.tsx +++ b/src/components/messages/Messages.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from "react"; import cstyles from "../common/Common.module.css"; import styles from "./Messages.module.css"; -import { ValueTransferClass, AddressBookEntryClass } from "../appstate"; +import { ValueTransferClass, AddressBookEntryClass, ValueTransferStatusEnum } from "../appstate"; import ScrollPaneBottom from "../scrollPane/ScrollPane"; import MessagesItemBlock from "./components/MessagesItemBlock"; import { BalanceBlock, BalanceBlockHighlight } from "../balanceBlock"; @@ -44,7 +44,9 @@ const Messages: React.FC = () => { // set somePending as well here when I know there is something new in ValueTransfers const pending: number = valueTransfers.length > 0 - ? valueTransfers.filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length + ? valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length : 0; setAnyPending(pending > 0); }, [valueTransfers]); diff --git a/src/components/receive/Receive.tsx b/src/components/receive/Receive.tsx index d0151398..ac53fa3b 100644 --- a/src/components/receive/Receive.tsx +++ b/src/components/receive/Receive.tsx @@ -9,6 +9,7 @@ import { TransparentAddressClass, UnifiedAddressClass, ValueTransferClass, + ValueTransferStatusEnum, } from "../appstate"; import ScrollPaneTop from "../scrollPane/ScrollPane"; import AddressBlock from "./components/AddressBlock"; @@ -50,7 +51,9 @@ const Receive: React.FC = () => { // set somePending as well here when I know there is something new in ValueTransfers const pending: number = valueTransfers.length > 0 - ? valueTransfers.filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length + ? valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length : 0; setAnyPending(pending > 0); }, [valueTransfers]); diff --git a/src/components/receive/components/AddressBlock.tsx b/src/components/receive/components/AddressBlock.tsx index 81369d75..53028dde 100644 --- a/src/components/receive/components/AddressBlock.tsx +++ b/src/components/receive/components/AddressBlock.tsx @@ -10,7 +10,13 @@ import styles from "../Receive.module.css"; import cstyles from "../../common/Common.module.css"; import Utils from "../../../utils/utils"; import { ContextApp } from "../../../context/ContextAppState"; -import { ServerChainNameEnum, TransparentAddressClass, UnifiedAddressClass, ValueTransferClass } from "../../appstate"; +import { + ServerChainNameEnum, + TransparentAddressClass, + UnifiedAddressClass, + ValueTransferClass, + ValueTransferStatusEnum, +} from "../../appstate"; import RPC from "../../../rpc/rpc"; import { ipcRenderer, isSandboxed } from "../../../electronBridge"; @@ -61,7 +67,10 @@ const AddressBlock: React.FC = ({ }, []); const anyPending: boolean = useMemo( - () => valueTransfers.some((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3), + () => + valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .some((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3), [valueTransfers], ); diff --git a/src/components/send/Send.tsx b/src/components/send/Send.tsx index 6d9c452e..a92a138b 100644 --- a/src/components/send/Send.tsx +++ b/src/components/send/Send.tsx @@ -1,7 +1,13 @@ import React, { useContext, useEffect, useState } from "react"; import styles from "./Send.module.css"; import cstyles from "../common/Common.module.css"; -import { ToAddrClass, SendPageStateClass, ValueTransferClass, ServerChainNameEnum } from "../appstate"; +import { + ToAddrClass, + SendPageStateClass, + ValueTransferClass, + ServerChainNameEnum, + ValueTransferStatusEnum, +} from "../appstate"; import Utils from "../../utils/utils"; import ScrollPaneTop from "../scrollPane/ScrollPane"; import { BalanceBlockHighlight } from "../balanceBlock"; @@ -50,7 +56,9 @@ const Send: React.FC = ({ sendTransaction, setSendPageState }) => { // set somePending as well here when I know there is something new in ValueTransfers const pending: number = valueTransfers.length > 0 - ? valueTransfers.filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length + ? valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length : 0; setAnyPending(pending > 0); }, [valueTransfers]); @@ -76,7 +84,9 @@ const Send: React.FC = ({ sendTransaction, setSendPageState }) => { // set somePending as well here when I know there is something new in ValueTransfers const pending: number = valueTransfers.length > 0 - ? valueTransfers.filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length + ? valueTransfers + .filter((vt: ValueTransferClass) => vt.status !== ValueTransferStatusEnum.failed) + .filter((vt: ValueTransferClass) => vt.confirmations >= 0 && vt.confirmations < 3).length : 0; // If there are unverified funds, then show a tooltip const unconfirmed: number = diff --git a/src/components/sideBar/Sidebar.tsx b/src/components/sideBar/Sidebar.tsx index ae10cff6..ef83b541 100644 --- a/src/components/sideBar/Sidebar.tsx +++ b/src/components/sideBar/Sidebar.tsx @@ -24,8 +24,10 @@ import { ipcRenderer, native } from "../../electronBridge"; // in place when the user clicks to copy. Seed phrase is intentionally NOT // copyable to the clipboard — see the note in the JSX. type SeedUfvkModalContentProps = { - seedStr: string; - ufvkStr: string; + // null = the native call is still in flight; render a loading state instead + // of empty content so the user gets feedback while get_seed / get_ufvk run. + seedStr: string | null; + ufvkStr: string | null; birthday: number | undefined; }; @@ -34,6 +36,27 @@ const SeedUfvkModalContent: React.FC = ({ seedStr, uf const { copied: birthdayCopied, copy: copyBirthday } = useCopy(1500); const birthdayStr = String(birthday ?? ""); + if (seedStr === null || ufvkStr === null) { + return ( +
+
Retrieving seed phrase / viewing key…
+ +
+ ); + } + + // Each sensitive value (seed, UFVK, birthday) lives inside this card so it's + // visually distinct from the surrounding explanatory text. Thin accent border + // and a slightly smaller font keep the data legible without dominating. + const dataBoxStyle: React.CSSProperties = { + border: "1px solid var(--color-primary)", + borderRadius: 4, + padding: 12, + marginTop: 8, + marginBottom: 16, + fontSize: "0.85em", + }; + return (
{!!seedStr && ( @@ -47,9 +70,9 @@ const SeedUfvkModalContent: React.FC = ({ seedStr, uf
Write this seed phrase down by hand. Do not copy it to the clipboard.
-
= ({ seedStr, uf > {seedStr}
-
)} {!!ufvkStr && ( @@ -68,14 +90,13 @@ const SeedUfvkModalContent: React.FC = ({ seedStr, uf
PLEASE KEEP IT SAFE!
-
Unified Full Viewing Key @@ -87,6 +108,7 @@ const SeedUfvkModalContent: React.FC = ({ seedStr, uf tabIndex={0} aria-label="Copy viewing key" style={{ + ...dataBoxStyle, cursor: "pointer", textAlign: "center", wordBreak: "break-word", @@ -103,7 +125,6 @@ const SeedUfvkModalContent: React.FC = ({ seedStr, uf > {ufvkStr}
-
)}
= ({ seedStr, uf justifyContent: "center", gap: 8, alignItems: "baseline", - marginBottom: 10, }} > Birthday @@ -123,6 +143,10 @@ const SeedUfvkModalContent: React.FC = ({ seedStr, uf tabIndex={0} aria-label="Copy birthday" style={{ + ...dataBoxStyle, + marginBottom: 0, + alignSelf: "center", + minWidth: 120, cursor: "pointer", textAlign: "center", fontFamily: "monospace, Roboto", @@ -351,14 +375,26 @@ const Sidebar: React.FC = ({ doRescan, navigateToLoadingScreenChan if (!authResult.success) return; } + // Open the modal with a loading state before the native fetches so the + // user gets immediate feedback. get_seed / get_ufvk can each take a + // noticeable moment, and previously the UI sat silent between the auth + // prompt closing and the modal appearing. + openErrorModal( + "Wallet Seed Phrase / Viewing Key", + , + ); + // Always fetch the UFVK — for seed wallets it's derived from the seed, and // showing it alongside the seed lets the user share view-only access without - // exposing spend authority. - const seedRaw: string = readOnlyRef.current ? "" : await native.get_seed(); - const ufvkRaw: string = await native.get_ufvk(); + // exposing spend authority. Run both native calls in parallel. + const [seedRaw, ufvkRaw] = await Promise.all([ + readOnlyRef.current ? Promise.resolve("") : native.get_seed(), + native.get_ufvk(), + ]); const seedStr: string = seedRaw ? (JSON.parse(seedRaw).seed_phrase ?? "") : ""; const ufvkStr: string = ufvkRaw ? (JSON.parse(ufvkRaw).ufvk ?? "") : ""; + if (!active) return; openErrorModal( "Wallet Seed Phrase / Viewing Key", , diff --git a/src/rpc/rpc.ts b/src/rpc/rpc.ts index f5508523..364f263c 100644 --- a/src/rpc/rpc.ts +++ b/src/rpc/rpc.ts @@ -391,7 +391,16 @@ export default class RPC { } else { const syncStr: string = await native.run_sync(); if (!syncStr || syncStr.toLowerCase().startsWith("error")) { - console.error(`Error sync ${syncStr}`); + // "sync is already running" is the expected outcome of the + // defensive run_sync() that fetchSyncPoll() fires while a sync is + // mid-flight (see the "is not complete" branch above). It's not a + // failure — the existing sync just keeps going. Demote to log so it + // doesn't drown the console with red. + if (syncStr && syncStr.toLowerCase().includes("already running")) { + console.log(`Sync already running: ${syncStr}`); + } else { + console.error(`Error sync ${syncStr}`); + } } } } catch (error) { @@ -680,11 +689,19 @@ export default class RPC { vt.blockheight = tx.blockheight; vt.status = tx.status; + + if (tx.status === ValueTransferStatusEnum.failed) { + console.log("[RPC] failed value transfer (raw):", tx); + } vt.address = !tx.recipient_address ? undefined : tx.recipient_address; vt.amount = (!tx.value ? 0 : tx.value) / 10 ** 8; vt.memos = !tx.memos || tx.memos.length === 0 ? undefined : tx.memos; vt.pool = !tx.pool_received ? undefined : tx.pool_received; + if (vt.status === ValueTransferStatusEnum.failed) { + console.log("[RPC] failed value transfer (transformed):", vt); + } + return vt; }); @@ -776,6 +793,15 @@ export default class RPC { } async getZecPrice() { + // Skip the network call entirely on testnet / regtest: TAZ has no USD + // price and the UI doesn't render the value anyway (see BalanceBlock, + // which only shows USD when currencyName === "ZEC"). Avoids unnecessary + // HTTP / Tor traffic every 5s on a testnet wallet. Wallet switches + // already reset the cached price via setZecPrice(0) in Routes.tsx. + if (this.currentWallet && this.currentWallet.chain_name !== ServerChainNameEnum.mainChainName) { + return; + } + // Read the user's "fetch price via Tor" preference from persisted // settings. Default off — if the key is missing or anything throws while // loading settings we keep the conventional HTTP path. The Tor client is diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 5580d089..18a4bd61 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -27,31 +27,33 @@ export default class Utils { ? "Send" : status === ValueTransferStatusEnum.failed && type === ValueTransferKindEnum.shield ? "Shield" - : type === ValueTransferKindEnum.sent && confirmations === 0 - ? "...Sending..." - : type === ValueTransferKindEnum.sent && confirmations > 0 - ? "Sent" - : type === ValueTransferKindEnum.received && confirmations === 0 - ? "...Receiving..." - : type === ValueTransferKindEnum.received && confirmations > 0 - ? "Received" - : type === ValueTransferKindEnum.memoToSelf && confirmations === 0 - ? "...Sending to self..." - : type === ValueTransferKindEnum.memoToSelf && confirmations > 0 - ? "Memo to self" - : type === ValueTransferKindEnum.sendToSelf && confirmations === 0 - ? "...Sending to self..." - : type === ValueTransferKindEnum.sendToSelf && confirmations > 0 - ? "Send to self" - : type === ValueTransferKindEnum.shield && confirmations === 0 - ? "...Shielding..." - : type === ValueTransferKindEnum.shield && confirmations > 0 - ? "Shield" - : type === ValueTransferKindEnum.rejection && confirmations === 0 - ? "...Sending..." - : type === ValueTransferKindEnum.rejection && confirmations > 0 - ? "Rejection" - : ""; + : status === ValueTransferStatusEnum.failed && type === ValueTransferKindEnum.received + ? "Receive" + : type === ValueTransferKindEnum.sent && confirmations === 0 + ? "...Sending..." + : type === ValueTransferKindEnum.sent && confirmations > 0 + ? "Sent" + : type === ValueTransferKindEnum.received && confirmations === 0 + ? "...Receiving..." + : type === ValueTransferKindEnum.received && confirmations > 0 + ? "Received" + : type === ValueTransferKindEnum.memoToSelf && confirmations === 0 + ? "...Sending to self..." + : type === ValueTransferKindEnum.memoToSelf && confirmations > 0 + ? "Memo to self" + : type === ValueTransferKindEnum.sendToSelf && confirmations === 0 + ? "...Sending to self..." + : type === ValueTransferKindEnum.sendToSelf && confirmations > 0 + ? "Send to self" + : type === ValueTransferKindEnum.shield && confirmations === 0 + ? "...Shielding..." + : type === ValueTransferKindEnum.shield && confirmations > 0 + ? "Shield" + : type === ValueTransferKindEnum.rejection && confirmations === 0 + ? "...Sending..." + : type === ValueTransferKindEnum.rejection && confirmations > 0 + ? "Rejection" + : ""; } static trimToSmall(addr?: string, numChars?: number): string {