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: 21 additions & 0 deletions src/components/MemoDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='inline-flex items-center gap-2'>
<span className='rounded-md border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
{label}
</span>

<span className='font-mono text-sm text-foreground'>{value}</span>
</div>
);
}
10 changes: 6 additions & 4 deletions src/components/confirmations/TokenConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,7 +27,7 @@ interface TokenConfirmationProps {
precision?: number;
name?: string;
amount?: string;
currentMemo?: string;
currentMemo?: Memo;
}

export function TokenConfirmation({
Expand Down Expand Up @@ -126,10 +128,10 @@ export function TokenConfirmation({
<ConfirmationCard>
<div className='flex items-center justify-between'>
<div className='break-words whitespace-pre-wrap flex-1'>
{currentMemo}
<MemoDisplay memo={currentMemo} />
</div>
<CopyButton
value={currentMemo}
value={formatMemo(currentMemo)}
className='h-4 w-4 shrink-0 ml-2'
onCopy={() => toast.success(t`Data copied to clipboard`)}
/>
Expand Down
34 changes: 30 additions & 4 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
);
}

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
208 changes: 146 additions & 62 deletions src/pages/Send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -62,7 +67,7 @@ export default function Send() {

const [asset, setAsset] = useState<TokenRecord | null>(null);
const [response, setResponse] = useState<TransactionResponse | null>(null);
const [currentMemo, setCurrentMemo] = useState<string | undefined>(undefined);
const [currentMemo, setCurrentMemo] = useState<Memo | undefined>(undefined);

const [bulk, setBulk] = useState(false);

Expand Down Expand Up @@ -108,55 +113,82 @@ 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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
memoMode: MemoMode.Text,
},
});
const memoMode = form.watch('memoMode') || MemoMode.Text;

const { handleScanOrPaste } = useScannerOrClipboard((scanResValue) => {
form.setValue('address', scanResValue);
});

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) {
Expand Down Expand Up @@ -337,27 +369,79 @@ export default function Send() {
)}
/>

<FormField
control={form.control}
name='memo'
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Memo (optional)</Trans>
</FormLabel>
<FormControl>
<Input
autoCorrect='off'
autoCapitalize='off'
autoComplete='off'
placeholder={t`Enter memo`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='col-span-2'>
<div className='flex items-center justify-between mb-2'>
<FormLabel>
<Trans>Memo (optional)</Trans>
</FormLabel>

<div className='flex items-center gap-2 text-sm'>
<span
className={
memoMode === 'text'
? 'font-medium'
: 'text-muted-foreground'
}
>
<Trans>Text</Trans>
</span>

<Switch
checked={memoMode === MemoMode.Hex}
onCheckedChange={(checked) => {
form.setValue(
'memoMode',
checked ? MemoMode.Hex : MemoMode.Text,
{
shouldValidate: true,
},
);
}}
/>

<span
className={
memoMode === MemoMode.Hex
? 'font-medium'
: 'text-muted-foreground'
}
>
<Trans>Hex</Trans>
</span>
</div>
</div>

<FormField
control={form.control}
name='memo'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
autoCorrect='off'
autoCapitalize='off'
autoComplete='off'
placeholder={
memoMode === MemoMode.Hex
? t`e.g. ff00ab`
: t`Enter memo`
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className='text-sm text-muted-foreground mt-2'>
{memoMode === MemoMode.Hex ? (
<Trans>Raw bytes (hex)</Trans>
) : (
<Trans>UTF-8 encoded</Trans>
)}
</div>
</div>

{!bulk && (
<div className='col-span-2'>
Expand Down
16 changes: 16 additions & 0 deletions src/types/CoinMemo.ts
Original file line number Diff line number Diff line change
@@ -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;
}