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 && (
}
iconPlacement="left"
size="lg"
diff --git a/apps/web/src/features/shared/memo-display.tsx b/apps/web/src/features/shared/memo-display.tsx
new file mode 100644
index 0000000000..9ccdcb3b98
--- /dev/null
+++ b/apps/web/src/features/shared/memo-display.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Button } from "@ui/button";
+import i18next from "i18next";
+import { useActiveAccount } from "@/core/hooks/use-active-account";
+import { getLoginType } from "@/utils/user-token";
+import { decryptMemo } from "@/utils/memo-crypto";
+import { error } from "@/features/shared";
+
+interface Props {
+ memo: string;
+}
+
+/**
+ * Heuristic to detect if a #-prefixed memo is actually encrypted.
+ *
+ * Encrypted memos on Hive are `#` followed by a long base58-encoded string
+ * (typically 150+ chars, no spaces, no punctuation outside base58 alphabet).
+ * Plain text memos starting with `#` will have spaces and regular text.
+ */
+function isLikelyEncryptedMemo(memo: string): boolean {
+ if (!memo.startsWith("#")) return false;
+ const content = memo.slice(1);
+ // Encrypted memos are long base58 strings with no whitespace
+ return content.length > 50 && !/\s/.test(content);
+}
+
+export function MemoDisplay({ memo }: Props) {
+ const { activeUser } = useActiveAccount();
+ const [decryptedText, setDecryptedText] = useState
(null);
+ const [isDecrypting, setIsDecrypting] = useState(false);
+
+ const encrypted = isLikelyEncryptedMemo(memo);
+
+ const handleDecrypt = useCallback(async () => {
+ if (!activeUser) return;
+
+ setIsDecrypting(true);
+ try {
+ const loginType = getLoginType(activeUser.username);
+ const result = await decryptMemo(loginType, activeUser.username, memo);
+ setDecryptedText(result);
+ } catch (e) {
+ error(i18next.t("transfer.memo-decrypt-error"));
+ } finally {
+ setIsDecrypting(false);
+ }
+ }, [activeUser, memo]);
+
+ if (decryptedText !== null) {
+ return (
+
+ π
+ {decryptedText}
+
+ );
+ }
+
+ if (!encrypted) {
+ // Plain text memo that happens to start with #
+ return (
+ {memo}
+ );
+ }
+
+ return (
+
+ π {i18next.t("transfer.memo-encrypted-label")}
+ {activeUser && (
+
+ )}
+
+ );
+}
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}
+
+ )}