diff --git a/apps/web/src/api/mutations/sign-transfer.ts b/apps/web/src/api/mutations/sign-transfer.ts index 922b1db921..714847622e 100644 --- a/apps/web/src/api/mutations/sign-transfer.ts +++ b/apps/web/src/api/mutations/sign-transfer.ts @@ -8,6 +8,8 @@ import { DEFAULT_DYNAMIC_PROPS } from "@/consts/default-dynamic-props"; import { getDynamicPropsQueryOptions } from "@ecency/sdk"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import { invalidateWalletQueries } from "@/features/wallet/utils/invalidate-wallet-queries"; +import { encryptMemo } from "@/utils/memo-crypto"; +import { getLoginType } from "@/utils/user-token"; import { useTransferMutation, useTransferPointMutation, @@ -81,31 +83,38 @@ export function useSignTransfer(mode: TransferMode, asset: TransferAsset) { return { isPending, mutateAsync: async ({ to, amount, memo }: SignTransferPayload) => { + // Encrypt memo if it starts with # + let processedMemo = memo; + if (memo.startsWith("#") && to) { + const loginType = getLoginType(activeUser?.username ?? ""); + processedMemo = await encryptMemo(loginType, activeUser!.username, to, memo.slice(1)); + } + const fullAmount = `${(+amount).toFixed(3)} ${asset}`; const requestId = Date.now() >>> 0; switch (mode) { case "transfer": if (asset === "POINT") { - await transferPoint.mutateAsync({ to, amount: fullAmount, memo }); + await transferPoint.mutateAsync({ to, amount: fullAmount, memo: processedMemo }); } else if (asset === "SPK") { await transferSpk.mutateAsync({ to, amount: parseFloat(amount) * 1000 }); } else if (asset === "LARYNX") { await transferLarynx.mutateAsync({ to, amount: parseFloat(amount) * 1000 }); } else if (asset !== "HIVE" && asset !== "HBD") { // Hive Engine token - await transferEngine.mutateAsync({ to, quantity: amount, symbol: asset, memo }); + await transferEngine.mutateAsync({ to, quantity: amount, symbol: asset, memo: processedMemo }); } else { - await transfer.mutateAsync({ to, amount: fullAmount, memo }); + await transfer.mutateAsync({ to, amount: fullAmount, memo: processedMemo }); } break; case "transfer-saving": - await toSavings.mutateAsync({ to, amount: fullAmount, memo }); + await toSavings.mutateAsync({ to, amount: fullAmount, memo: processedMemo }); break; case "withdraw-saving": - await fromSavings.mutateAsync({ to, amount: fullAmount, memo, requestId }); + await fromSavings.mutateAsync({ to, amount: fullAmount, memo: processedMemo, requestId }); break; case "convert": @@ -117,7 +126,7 @@ export function useSignTransfer(mode: TransferMode, asset: TransferAsset) { break; case "claim-interest": - await claimInterest.mutateAsync({ to, amount: fullAmount, memo, requestId }); + await claimInterest.mutateAsync({ to, amount: fullAmount, memo: processedMemo, requestId }); break; case "power-up": diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hbd/_components/hive-transaction-row.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hbd/_components/hive-transaction-row.tsx index 36a44d0cec..42b8e12d9b 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hbd/_components/hive-transaction-row.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hbd/_components/hive-transaction-row.tsx @@ -6,6 +6,7 @@ import { formattedNumber, parseAsset } from "@/utils"; import { UilArrowRight, UilRefresh } from "@tooni/iconscout-unicons-react"; import { ProfileWalletTokenHistoryHiveItem } from "../../_components"; import { Badge } from "@/features/ui"; +import { MemoDisplay } from "@/features/shared/memo-display"; interface Props { transaction: Transaction; @@ -78,7 +79,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
); @@ -107,7 +112,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) { details = (
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
{recurrentDescription}
@@ -145,7 +154,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
); diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hive/_components/hive-transaction-row.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hive/_components/hive-transaction-row.tsx index c92a42f528..6cd841b07b 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hive/_components/hive-transaction-row.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hive/_components/hive-transaction-row.tsx @@ -16,6 +16,7 @@ import { formattedNumber, parseAsset } from "@/utils"; import { UilArrowRight, UilRefresh } from "@tooni/iconscout-unicons-react"; import { ProfileWalletTokenHistoryHiveItem } from "../../_components"; import { Badge } from "@/features/ui"; +import { MemoDisplay } from "@/features/shared/memo-display"; interface Props { transaction: Transaction; @@ -123,7 +124,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
); @@ -153,7 +158,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) { details = (
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
{recurrentDescription}
diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hive-transaction-row.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hive-transaction-row.tsx index 0d3ea6b473..eda80d4049 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hive-transaction-row.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/hp/_components/hive-transaction-row.tsx @@ -11,6 +11,7 @@ import { UilArrowRight, UilRefresh } from "@tooni/iconscout-unicons-react"; import { useMemo } from "react"; import { ProfileWalletTokenHistoryHiveItem } from "../../_components"; import { Badge } from "@/features/ui"; +import { MemoDisplay } from "@/features/shared/memo-display"; interface Props { transaction: Transaction; @@ -124,7 +125,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
); @@ -168,7 +173,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) { details = (
{tr.memo ? ( -
{tr.memo}
+ tr.memo.startsWith("#") ? ( + + ) : ( +
{tr.memo}
+ ) ) : null}
{recurrentDescription}
diff --git a/apps/web/src/app/client-providers.tsx b/apps/web/src/app/client-providers.tsx index cb7db6ff22..31f809da3d 100644 --- a/apps/web/src/app/client-providers.tsx +++ b/apps/web/src/app/client-providers.tsx @@ -8,6 +8,7 @@ import { getQueryClient } from "@/core/react-query"; import { Announcements } from "@/features/announcement"; import { Tracker } from "@/features/monitoring"; import { AuthUpgradeDialog } from "@/features/shared/auth-upgrade"; +import { MemoKeyDialog } from "@/features/shared/memo-key"; import { PushNotificationsProvider } from "@/features/push-notifications"; import { UserActivityRecorder } from "@/features/user-activity"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -45,6 +46,7 @@ export function ClientProviders(props: PropsWithChildren) { {/* Defer non-critical components for LCP optimization */} + visionFeatures.userActivityTracking.enabled} > diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 1ebf71c2e7..0311634c47 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -1463,6 +1463,13 @@ "memo": "Memo", "memo-placeholder": "enter your notes here", "memo-help": "This memo is public", + "memo-encrypted": "This memo will be encrypted", + "memo-decrypt": "Decrypt", + "memo-decrypt-error": "Could not decrypt memo. Check your memo key.", + "memo-encrypted-label": "Encrypted memo", + "memo-key-title": "Enter Memo Key", + "memo-key-subtitle-encrypt": "Your memo key is needed to encrypt this message.", + "memo-key-subtitle-decrypt": "Your memo key is needed to decrypt this message.", "memo-error": "Never put private keys into a memo field, it is publicly visible to everyone.", "memo-required": "Transfer to an exchange must have a memo.", "invalid-asset": "You can only send HIVE to an exchange account.", diff --git a/apps/web/src/features/polls/components/poll-widget.tsx b/apps/web/src/features/polls/components/poll-widget.tsx index 2e37e7323a..76dbca3b32 100644 --- a/apps/web/src/features/polls/components/poll-widget.tsx +++ b/apps/web/src/features/polls/components/poll-widget.tsx @@ -196,7 +196,7 @@ export function PollWidget({ poll, isReadOnly, entry, compact = false }: Props)
{showVote && ( + )} +
+ ); +} diff --git a/apps/web/src/features/shared/memo-key/index.ts b/apps/web/src/features/shared/memo-key/index.ts new file mode 100644 index 0000000000..c6cc66c564 --- /dev/null +++ b/apps/web/src/features/shared/memo-key/index.ts @@ -0,0 +1,2 @@ +export { requestMemoKey, getTempMemoKey, clearTempMemoKey } from "./memo-key-events"; +export { MemoKeyDialog } from "./memo-key-dialog"; diff --git a/apps/web/src/features/shared/memo-key/memo-key-dialog.tsx b/apps/web/src/features/shared/memo-key/memo-key-dialog.tsx new file mode 100644 index 0000000000..6d2d9641e1 --- /dev/null +++ b/apps/web/src/features/shared/memo-key/memo-key-dialog.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Modal, ModalBody, ModalHeader, ModalTitle } from "@ui/modal"; +import { KeyInput } from "@ui/input"; +import i18next from "i18next"; +import { PrivateKey } from "@hiveio/dhive"; +import { resolveMemoKey } from "./memo-key-events"; + +interface MemoKeyRequest { + purpose: "encrypt" | "decrypt"; +} + +export function MemoKeyDialog() { + const [request, setRequest] = useState(null); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + setRequest(detail); + }; + window.addEventListener("ecency-memo-key", handler); + return () => window.removeEventListener("ecency-memo-key", handler); + }, []); + + const handleClose = useCallback(() => { + setRequest(null); + resolveMemoKey(false); + }, []); + + const handleKeySign = useCallback((privateKey: PrivateKey) => { + setRequest(null); + resolveMemoKey(privateKey.toString()); + }, []); + + if (!request) return null; + + const subtitle = + request.purpose === "encrypt" + ? i18next.t("transfer.memo-key-subtitle-encrypt") + : i18next.t("transfer.memo-key-subtitle-decrypt"); + + return ( + + + {i18next.t("transfer.memo-key-title")} + + +

{subtitle}

+ +
+
+ ); +} diff --git a/apps/web/src/features/shared/memo-key/memo-key-events.ts b/apps/web/src/features/shared/memo-key/memo-key-events.ts new file mode 100644 index 0000000000..991dedbd74 --- /dev/null +++ b/apps/web/src/features/shared/memo-key/memo-key-events.ts @@ -0,0 +1,67 @@ +/** + * Imperative API for the memo key dialog. + * + * Used by the memo crypto helper to prompt the user for their memo private key + * when encrypting or decrypting memos. + * + * Pattern follows the auth-upgrade system (CustomEvent-based imperative UI). + */ + +let pendingResolve: ((key: string | false) => void) | null = null; +let tempMemoKey: string | null = null; +let tempKeyTimeout: ReturnType | null = null; + +/** + * Show the memo key dialog and wait for user input. + * Returns the memo private key (WIF) or false if the user cancels. + */ +export function requestMemoKey( + purpose: "encrypt" | "decrypt" +): Promise { + if (pendingResolve) { + pendingResolve(false); + pendingResolve = null; + } + + clearTempMemoKey(); + + return new Promise((resolve) => { + pendingResolve = resolve; + window.dispatchEvent( + new CustomEvent("ecency-memo-key", { + detail: { purpose } + }) + ); + }); +} + +/** + * Called by the dialog when the user provides a key or cancels. + */ +export function resolveMemoKey(key: string | false) { + if (typeof key === "string" && key) { + tempMemoKey = key; + if (tempKeyTimeout) clearTimeout(tempKeyTimeout); + tempKeyTimeout = setTimeout(clearTempMemoKey, 60_000); + } + pendingResolve?.(key); + pendingResolve = null; +} + +/** + * Retrieve the cached memo key (non-destructive read). + */ +export function getTempMemoKey(): string | null { + return tempMemoKey; +} + +/** + * Clear the cached memo key. + */ +export function clearTempMemoKey() { + tempMemoKey = null; + if (tempKeyTimeout) { + clearTimeout(tempKeyTimeout); + tempKeyTimeout = null; + } +} diff --git a/apps/web/src/features/shared/transactions/transaction-row.tsx b/apps/web/src/features/shared/transactions/transaction-row.tsx index 7ababe60a1..884ad82da1 100644 --- a/apps/web/src/features/shared/transactions/transaction-row.tsx +++ b/apps/web/src/features/shared/transactions/transaction-row.tsx @@ -21,6 +21,7 @@ import { DEFAULT_DYNAMIC_PROPS } from "@/consts/default-dynamic-props"; import { getDynamicPropsQueryOptions } from "@ecency/sdk"; import { Transaction } from "@/entities"; import { useQuery } from "@tanstack/react-query"; +import { MemoDisplay } from "@/features/shared/memo-display"; interface Props { transaction: Transaction; @@ -141,9 +142,11 @@ export function TransactionRow({ entry, transaction: item }: Props) { details = ( {tr.memo ? ( - <> - {tr.memo}

- + tr.memo.startsWith("#") ? ( + <>

+ ) : ( + <>{tr.memo}

+ ) ) : null} <> @{tr.from} -> @{tr.to} @@ -178,9 +181,11 @@ export function TransactionRow({ entry, transaction: item }: Props) { details = ( {tr.memo ? ( - <> - {tr.memo}

- + tr.memo.startsWith("#") ? ( + <>

+ ) : ( + <>{tr.memo}

+ ) ) : null} {tr.type === "recurrent_transfer" ? ( <> diff --git a/apps/web/src/features/shared/transfer/transfer-step-1.tsx b/apps/web/src/features/shared/transfer/transfer-step-1.tsx index 16aeb6fa9c..37098f6f8c 100644 --- a/apps/web/src/features/shared/transfer/transfer-step-1.tsx +++ b/apps/web/src/features/shared/transfer/transfer-step-1.tsx @@ -470,7 +470,14 @@ export function TransferStep1({ titleLngKey }: Props) { value={memo} onChange={memoChanged} /> - + {memoError && }
diff --git a/apps/web/src/features/shared/transfer/transfer-step-2.tsx b/apps/web/src/features/shared/transfer/transfer-step-2.tsx index aa158254eb..5d61fc32bf 100644 --- a/apps/web/src/features/shared/transfer/transfer-step-2.tsx +++ b/apps/web/src/features/shared/transfer/transfer-step-2.tsx @@ -53,7 +53,16 @@ export function TransferStep2({ titleLngKey }: Props) { {hpToVests(Number(amount), (dynamicProps ?? DEFAULT_DYNAMIC_PROPS).hivePerMVests)}
)} - {memo &&
{memo}
} + {memo && ( +
+ {memo.startsWith("#") && ( + + πŸ”’ {i18next.t("transfer.memo-encrypted-label")} + + )} + {memo} +
+ )}