Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions apps/web/src/api/mutations/sign-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
Comment on lines 85 to +91
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only encrypt in branches that actually submit a memo.

This runs before the mode/asset switch, so # memos on paths like SPK, LARYNX, power-up, delegate, and convert will still request a memo key even though those mutations never send memo. That turns unsupported memo input into an avoidable prompt/failure path. Move the encryption into the branches that pass memo, or gate it with an explicit supportsMemo check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/api/mutations/sign-transfer.ts` around lines 85 - 91, The code
currently encrypts memo unconditionally when it starts with "#" inside
mutateAsync (using getLoginType, encryptMemo, activeUser, processedMemo), which
can prompt for a memo key even for transfer paths that never submit a memo; move
the encryption logic into the specific branches that actually send a memo (the
branches that call the mutation code that includes processedMemo) or add an
explicit supportsMemo check before calling encryptMemo so only paths that will
include memo perform encryption and request keys.


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":
Expand All @@ -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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,7 +79,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
<div className="space-y-2">
<TransferParticipants from={tr.from} to={tr.to} />
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
</div>
);
Expand Down Expand Up @@ -107,7 +112,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
details = (
<div className="space-y-2">
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
<div className="text-sm text-gray-600 dark:text-gray-400">{recurrentDescription}</div>
<TransferParticipants from={tr.from} to={tr.to} />
Expand Down Expand Up @@ -145,7 +154,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
<div className="space-y-2">
<TransferParticipants from={tr.from} to={tr.to} />
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,7 +124,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
<div className="space-y-2">
<TransferParticipants from={tr.from} to={tr.to} />
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
</div>
);
Expand Down Expand Up @@ -153,7 +158,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
details = (
<div className="space-y-2">
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
<div className="text-sm text-gray-600 dark:text-gray-400">{recurrentDescription}</div>
<TransferParticipants from={tr.from} to={tr.to} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -124,7 +125,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
<div className="space-y-2">
<TransferParticipants from={tr.from} to={tr.to} />
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
</div>
);
Expand Down Expand Up @@ -168,7 +173,11 @@ export function HiveTransactionRow({ entry, transaction: tr }: Props) {
details = (
<div className="space-y-2">
{tr.memo ? (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
tr.memo.startsWith("#") ? (
<MemoDisplay memo={tr.memo} />
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{tr.memo}</div>
)
) : null}
<div className="text-sm text-gray-600 dark:text-gray-400">{recurrentDescription}</div>
<TransferParticipants from={tr.from} to={tr.to} />
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/client-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ export function ClientProviders(props: PropsWithChildren) {
{/* Defer non-critical components for LCP optimization */}
<DeferredRender>
<AuthUpgradeDialog />
<MemoKeyDialog />
Comment on lines 47 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mount MemoKeyDialog eagerly; deferring it can drop the first memo-key request.

MemoKeyDialog installs its ecency-memo-key listener in useEffect (apps/web/src/features/shared/memo-key/memo-key-dialog.tsx:14-24). Putting it behind DeferredRender adds another async delay before that listener exists, so any early requestMemoKey() call is missed and the dialog never opens.

Suggested fix
         <UIManager>
           <ClientInit />
+          <MemoKeyDialog />
           {/* Defer non-critical components for LCP optimization */}
           <DeferredRender>
             <AuthUpgradeDialog />
-            <MemoKeyDialog />
             <EcencyConfigManager.Conditional
               condition={({ visionFeatures }) => visionFeatures.userActivityTracking.enabled}
             >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<DeferredRender>
<AuthUpgradeDialog />
<MemoKeyDialog />
<UIManager>
<ClientInit />
<MemoKeyDialog />
{/* Defer non-critical components for LCP optimization */}
<DeferredRender>
<AuthUpgradeDialog />
<EcencyConfigManager.Conditional
condition={({ visionFeatures }) => visionFeatures.userActivityTracking.enabled}
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/client-providers.tsx` around lines 47 - 49, MemoKeyDialog is
currently wrapped by DeferredRender which delays mounting and causes its
ecency-memo-key listener (installed in MemoKeyDialog's useEffect) to miss early
requestMemoKey() calls; move <MemoKeyDialog /> out of the <DeferredRender> block
so it mounts eagerly (e.g., place MemoKeyDialog alongside AuthUpgradeDialog or
above DeferredRender) so the listener is registered immediately. Ensure you only
change the component placement (remove from DeferredRender) and do not alter
MemoKeyDialog internals.

<EcencyConfigManager.Conditional
condition={({ visionFeatures }) => visionFeatures.userActivityTracking.enabled}
>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment on lines +1470 to +1472
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Call out that this is the private memo key.

"Enter Memo Key" / "Your memo key is needed" is ambiguous in Hive terminology and can easily lead users to paste the public memo key, which guarantees encrypt/decrypt failures. Please make the copy explicit, e.g. “Enter Private Memo Key”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/features/i18n/locales/en-US.json` around lines 1470 - 1472,
Update the three locale strings to explicitly reference the private memo key to
avoid confusion: change "memo-key-title" from "Enter Memo Key" to "Enter Private
Memo Key" and change both "memo-key-subtitle-encrypt" and
"memo-key-subtitle-decrypt" to mention "private memo key" (e.g., "Your private
memo key is needed to encrypt this message." and "Your private memo key is
needed to decrypt this message."). Ensure the keys "memo-key-title",
"memo-key-subtitle-encrypt", and "memo-key-subtitle-decrypt" are updated
accordingly.

"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.",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/polls/components/poll-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export function PollWidget({ poll, isReadOnly, entry, compact = false }: Props)
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 items-center mt-4">
{showVote && (
<Button
disabled={isReadOnly || activeChoices.size === 0 || isVoting}
disabled={isReadOnly || !activeUser || activeChoices.size === 0 || isVoting}
icon={<UilPanelAdd />}
iconPlacement="left"
size="lg"
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/features/shared/memo-display.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +22 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent encrypted memo detection with calling components.

The isLikelyEncryptedMemo heuristic requires content.length > 50, but the components that route memos to MemoDisplay (e.g., transaction-row.tsx at lines 144-150, 183-189 and transfer-step-2.tsx at lines 56-65) use only memo.startsWith("#") without the length check.

This creates a UX problem: a short encrypted memo (≤50 chars) would be routed to MemoDisplay but then rendered as plain text (garbled base58). Consider either:

  1. Export and use isLikelyEncryptedMemo in calling components for consistent detection
  2. Lower the threshold (dhive's minimum encrypted output is shorter)
  3. Have MemoDisplay always attempt decryption for #-prefixed memos, handling failure gracefully
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/features/shared/memo-display.tsx` around lines 22 - 27, The
memo-detection heuristic in isLikelyEncryptedMemo (checks startsWith("#") plus
content.length > 50) is inconsistent with callers that only use
memo.startsWith("#"), causing short encrypted memos to be misrouted; export
isLikelyEncryptedMemo from memo-display and replace the simple
memo.startsWith("#") checks in the calling components (the code that routes to
MemoDisplay, e.g., where transaction-row and transfer-step-2 decide rendering)
to call isLikelyEncryptedMemo instead, or if you prefer shorter thresholds
adjust the length constant inside isLikelyEncryptedMemo and update all callers
to import it; also ensure MemoDisplay’s decryption path handles failures
gracefully (catch and fall back to safe plain-text rendering) so decryption
attempts don’t break the UI.


export function MemoDisplay({ memo }: Props) {
const { activeUser } = useActiveAccount();
const [decryptedText, setDecryptedText] = useState<string | null>(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 (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">
<span className="text-xs text-gray-500">🔓 </span>
{decryptedText}
</div>
);
}

if (!encrypted) {
// Plain text memo that happens to start with #
return (
<div className="text-sm text-gray-600 dark:text-gray-400 break-words">{memo}</div>
);
}

return (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>🔒 {i18next.t("transfer.memo-encrypted-label")}</span>
{activeUser && (
<Button
size="xs"
appearance="secondary"
outline={true}
disabled={isDecrypting}
onClick={handleDecrypt}
>
{i18next.t("transfer.memo-decrypt")}
</Button>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/features/shared/memo-key/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { requestMemoKey, getTempMemoKey, clearTempMemoKey } from "./memo-key-events";
export { MemoKeyDialog } from "./memo-key-dialog";
54 changes: 54 additions & 0 deletions apps/web/src/features/shared/memo-key/memo-key-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<MemoKeyRequest | null>(null);

useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<MemoKeyRequest>).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 (
<Modal show={true} centered={true} onHide={handleClose}>
<ModalHeader closeButton={true}>
<ModalTitle>{i18next.t("transfer.memo-key-title")}</ModalTitle>
</ModalHeader>
<ModalBody>
<p className="text-sm text-gray-600 mb-4">{subtitle}</p>
<KeyInput onSign={handleKeySign} keyType="memo" />
</ModalBody>
</Modal>
);
}
Loading