From e82e43bccd8bb92d1512b6e0529e628aa4bf14de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Hadam=C4=8D=C3=ADk?= Date: Sun, 29 Mar 2026 03:10:31 +0200 Subject: [PATCH] Add memo support with text and hex modes --- src/components/MemoDisplay.tsx | 21 ++ .../confirmations/TokenConfirmation.tsx | 10 +- src/lib/utils.ts | 34 ++- src/pages/Send.tsx | 208 ++++++++++++------ src/types/CoinMemo.ts | 16 ++ 5 files changed, 219 insertions(+), 70 deletions(-) create mode 100644 src/components/MemoDisplay.tsx create mode 100644 src/types/CoinMemo.ts 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; +} +