diff --git a/src/components/MemoDisplay.tsx b/src/components/MemoDisplay.tsx
new file mode 100644
index 000000000..c86bd7077
--- /dev/null
+++ b/src/components/MemoDisplay.tsx
@@ -0,0 +1,21 @@
+import { formatMemo, Memo, MemoMode } from '@/types/CoinMemo.ts';
+
+interface MemoDisplayProps {
+ memo: Memo;
+}
+
+export function MemoDisplay({ memo }: MemoDisplayProps) {
+ const isHex = memo.mode === MemoMode.Hex;
+ const label = isHex ? 'Hex' : 'Text';
+ const value = formatMemo(memo);
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ );
+}
diff --git a/src/components/confirmations/TokenConfirmation.tsx b/src/components/confirmations/TokenConfirmation.tsx
index 07373cb20..dad144191 100644
--- a/src/components/confirmations/TokenConfirmation.tsx
+++ b/src/components/confirmations/TokenConfirmation.tsx
@@ -5,9 +5,11 @@ import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { CoinsIcon, MergeIcon, SplitIcon } from 'lucide-react';
import { toast } from 'react-toastify';
-import { formatNumber } from '../../i18n';
+import { formatNumber } from '@/i18n.ts';
import { ConfirmationAlert } from './ConfirmationAlert';
import { ConfirmationCard } from './ConfirmationCard';
+import { formatMemo, Memo } from '@/types/CoinMemo.ts';
+import { MemoDisplay } from '@/components/MemoDisplay.tsx';
type TokenOperationType =
| 'split'
@@ -25,7 +27,7 @@ interface TokenConfirmationProps {
precision?: number;
name?: string;
amount?: string;
- currentMemo?: string;
+ currentMemo?: Memo;
}
export function TokenConfirmation({
@@ -126,10 +128,10 @@ export function TokenConfirmation({
- {currentMemo}
+
toast.success(t`Data copied to clipboard`)}
/>
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index a49b5e33e..380b6dc55 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -151,7 +151,7 @@ export interface AddressInfo {
export function toAddress(puzzleHash: string, prefix: string): string {
return bech32m.encode(
prefix,
- bech32m.toWords(fromHex(sanitizeHex(puzzleHash))),
+ bech32m.toWords(fromHex(normalizeHex(puzzleHash))),
);
}
@@ -199,8 +199,13 @@ export function isValidAssetId(assetId: string): boolean {
return /^[a-fA-F0-9]{64}$/.test(assetId);
}
-function sanitizeHex(hex: string): string {
- return hex.replace(/0x/i, '');
+function normalizeHex(hex: string): string {
+ return hex.replace(/\s+/g, '').replace(/^0x/i, '');
+}
+
+export function withHexPrefix(hex: string): string {
+ const normalized = normalizeHex(hex);
+ return '0x' + normalized;
}
const HEX_STRINGS = '0123456789abcdef';
@@ -250,9 +255,30 @@ function fromHex(hex: string): Uint8Array {
}
export function decodeHexMessage(hexMessage: string): string {
- return new TextDecoder().decode(fromHex(sanitizeHex(hexMessage)));
+ return new TextDecoder().decode(fromHex(normalizeHex(hexMessage)));
}
export function isHex(str: string): boolean {
return /^(0x)?[0-9a-fA-F]+$/.test(str);
}
+
+export function utf8ToBytes(value: string): Uint8Array {
+ return new TextEncoder().encode(value);
+}
+
+export function isValidHexBytes(hex: string): boolean {
+ const normalized = normalizeHex(hex);
+ return (
+ normalized.length % 2 === 0 && /^(?:[0-9a-fA-F]{2})*$/.test(normalized)
+ );
+}
+
+export function fromHexStrict(hex: string): Uint8Array {
+ const normalized = normalizeHex(hex);
+
+ if (!isValidHexBytes(normalized)) {
+ throw new Error('Invalid hex bytes');
+ }
+
+ return fromHex(normalized);
+}
diff --git a/src/pages/Send.tsx b/src/pages/Send.tsx
index 27c7a1657..2ad439939 100644
--- a/src/pages/Send.tsx
+++ b/src/pages/Send.tsx
@@ -28,7 +28,15 @@ import { useDefaultClawback } from '@/hooks/useDefaultClawback';
import { useErrors } from '@/hooks/useErrors';
import { useScannerOrClipboard } from '@/hooks/useScannerOrClipboard';
import { amount, positiveAmount } from '@/lib/formTypes';
-import { fromMojos, toDecimal, toHex, toMojos } from '@/lib/utils';
+import {
+ fromHexStrict,
+ fromMojos,
+ isValidHexBytes,
+ toDecimal,
+ toHex,
+ toMojos,
+ utf8ToBytes,
+} from '@/lib/utils';
import { useWalletState } from '@/state';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro';
@@ -45,10 +53,7 @@ import {
TokenRecord,
TransactionResponse,
} from '../bindings';
-
-function stringToUint8Array(str: string): Uint8Array {
- return new TextEncoder().encode(str);
-}
+import { Memo, MemoMode } from '@/types/CoinMemo.ts';
export default function Send() {
let { asset_id: assetId = null } = useParams();
@@ -62,7 +67,7 @@ export default function Send() {
const [asset, setAsset] = useState(null);
const [response, setResponse] = useState(null);
- const [currentMemo, setCurrentMemo] = useState(undefined);
+ const [currentMemo, setCurrentMemo] = useState(undefined);
const [bulk, setBulk] = useState(false);
@@ -108,42 +113,61 @@ export default function Send() {
}
};
- const formSchema = z.object({
- address: z
- .string()
- .refine(
- (address) =>
- Promise.all(
- addressList(address).map((address) =>
- commands.validateAddress(address).catch(addError),
- ),
- ).then((values) => values.every(Boolean)),
- bulk ? t`Invalid addresses` : t`Invalid address`,
+ const formSchema = z
+ .object({
+ address: z
+ .string()
+ .refine(
+ (address) =>
+ Promise.all(
+ addressList(address).map((address) =>
+ commands.validateAddress(address).catch(addError),
+ ),
+ ).then((values) => values.every(Boolean)),
+ bulk ? t`Invalid addresses` : t`Invalid address`,
+ ),
+ amount: positiveAmount(asset?.precision || 12).refine(
+ (amount) =>
+ asset
+ ? BigNumber(amount).lte(
+ toDecimal(asset.selectable_balance, asset.precision),
+ )
+ : true,
+ 'Amount exceeds spendable balance',
),
- amount: positiveAmount(asset?.precision || 12).refine(
- (amount) =>
- asset
- ? BigNumber(amount).lte(
- toDecimal(asset.selectable_balance, asset.precision),
- )
- : true,
- 'Amount exceeds spendable balance',
- ),
- fee: amount(walletState.sync.unit.precision).optional(),
- memo: z.string().optional(),
- clawbackEnabled: z.boolean().optional(),
- clawback: z
- .object({
- days: z.string(),
- hours: z.string(),
- minutes: z.string(),
- })
- .optional(),
- });
+ fee: amount(walletState.sync.unit.precision).optional(),
+ memo: z.string().optional(),
+ memoMode: z.nativeEnum(MemoMode),
+ clawbackEnabled: z.boolean().optional(),
+ clawback: z
+ .object({
+ days: z.string(),
+ hours: z.string(),
+ minutes: z.string(),
+ })
+ .optional(),
+ })
+ .superRefine((values, ctx) => {
+ if (
+ values.memoMode === MemoMode.Hex &&
+ values.memo &&
+ !isValidHexBytes(values.memo)
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['memo'],
+ message: t`Memo must be valid hex bytes (even length, 0-9, a-f)`,
+ });
+ }
+ });
const form = useForm>({
resolver: zodResolver(formSchema),
+ defaultValues: {
+ memoMode: MemoMode.Text,
+ },
});
+ const memoMode = form.watch('memoMode') || MemoMode.Text;
const { handleScanOrPaste } = useScannerOrClipboard((scanResValue) => {
form.setValue('address', scanResValue);
@@ -151,12 +175,20 @@ export default function Send() {
const onSubmit = () => {
const values = form.getValues();
- const memos = values.memo ? [toHex(stringToUint8Array(values.memo))] : [];
- // Store the memo for the confirmation dialog
- setCurrentMemo(values.memo);
+ let memos: string[] = [];
+
+ if (values.memo) {
+ const memoBytes =
+ values.memoMode === MemoMode.Hex
+ ? fromHexStrict(values.memo)
+ : utf8ToBytes(values.memo);
+
+ memos = [toHex(memoBytes)];
+ }
+
+ setCurrentMemo(values.memo ? {mode: values.memoMode, value: values.memo} : undefined);
- // Calculate clawback seconds if enabled
let clawback: number | null = null;
if (values.clawbackEnabled && values.clawback) {
@@ -337,27 +369,79 @@ export default function Send() {
)}
/>
- (
-
-
- Memo (optional)
-
-
-
-
-
-
- )}
- />
+
+
+
+ Memo (optional)
+
+
+
+
+ Text
+
+
+ {
+ form.setValue(
+ 'memoMode',
+ checked ? MemoMode.Hex : MemoMode.Text,
+ {
+ shouldValidate: true,
+ },
+ );
+ }}
+ />
+
+
+ Hex
+
+
+
+
+
(
+
+
+
+
+
+
+ )}
+ />
+
+
+ {memoMode === MemoMode.Hex ? (
+ Raw bytes (hex)
+ ) : (
+ UTF-8 encoded
+ )}
+
+
{!bulk && (
diff --git a/src/types/CoinMemo.ts b/src/types/CoinMemo.ts
new file mode 100644
index 000000000..dc9ef65da
--- /dev/null
+++ b/src/types/CoinMemo.ts
@@ -0,0 +1,16 @@
+import { withHexPrefix } from '@/lib/utils.ts';
+
+export enum MemoMode {
+ Text = 'text',
+ Hex = 'hex',
+}
+
+export interface Memo {
+ mode: MemoMode;
+ value: string;
+}
+
+export function formatMemo(memo: Memo): string {
+ return memo.mode === MemoMode.Hex ? withHexPrefix(memo.value) : memo.value;
+}
+