Skip to content

Commit

Permalink
feat(SPV-971): Added validation for xpriv and xpub fields
Browse files Browse the repository at this point in the history
  • Loading branch information
Nazarii-4chain committed Aug 27, 2024
1 parent 0aae4f1 commit 0db38ce
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 61 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"sonner": "^1.4.41",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"use-debounce": "^10.0.1",
"use-debounce": "^10.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
Expand Down
195 changes: 139 additions & 56 deletions src/components/AddXpubDialog/AddXpubDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,55 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog.tsx';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input.tsx';
import { Label } from '@/components/ui/label.tsx';
import { useSpvWalletClient } from '@/contexts';
import { errorWrapper } from '@/utils';
import { HD } from '@bsv/sdk';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CirclePlus } from 'lucide-react';
import { CirclePlus, CircleX } from 'lucide-react';

import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';

import { toast } from 'sonner';
import { useDebouncedCallback } from 'use-debounce';
import { z } from 'zod';

interface AddXpubDialogProps {
className?: string;
}

const formSchema = z.object({
xPriv: z.union([
z
.string()
.refine((val) => val.startsWith('xprv'), 'xPriv should start with xprv')
.refine((val) => val.length === 111, 'Invalid xPriv length.'),
z.literal(''),
]),
xPub: z
.string()
.refine((val) => val.startsWith('xpub'), 'xPub should starts with xpub.')
.refine((val) => val.length === 111, 'Invalid xPub length.'),
});

export const AddXpubDialog = ({ className }: AddXpubDialogProps) => {
const [isOpen, setIsOpen] = useState(false);
const [xPriv, setXPriv] = useState<string>('');
const [xPub, setXPub] = useState<string>('');
const queryClient = useQueryClient();

const xPrivRef = useRef<HTMLInputElement>(null);
const xPubRef = useRef<HTMLInputElement>(null);

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
xPriv: undefined,
xPub: undefined,
},
});

const { spvWalletClient } = useSpvWalletClient();

const mutation = useMutation({
Expand All @@ -40,53 +67,81 @@ export const AddXpubDialog = ({ className }: AddXpubDialogProps) => {
onSuccess: () => queryClient.invalidateQueries(),
});

const handleXPrivChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setXPriv(event.target.value);
};
useEffect(() => {
form.reset();
}, [isOpen]);

const handleXPubChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setXPub(event.target.value);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
const { xPub } = values;
try {
HD.fromString(values.xPub);
await mutation.mutateAsync(xPub);
toast.success('xPub successfully added');
form.reset();
} catch (error) {
toast.error('Unable to add xPub');
errorWrapper(error);
}
};

useEffect(() => {
if (!xPriv) {
const debouncedXPriv = useDebouncedCallback(() => {
const parsedXPriv = formSchema.safeParse({ xPriv: form.getValues('xPriv') });

if (form.getValues('xPriv').length === 0) {
form.clearErrors();
return;
}
form.trigger('xPriv');

if (!parsedXPriv.success) {
return;
}

try {
const xPrivHD = HD.fromString(xPriv);
const xPrivHD = HD.fromString(parsedXPriv.data.xPriv);
const xPubString = xPrivHD.toPublic().toString();
setXPub(xPubString);
form.setValue('xPub', xPubString);
toast.success('Converted xPriv to xPub');
} catch (error) {
toast.error('Unable to convert xPriv to xPub');
setXPub('');
}
}, [xPriv]);
}, 700);

useEffect(() => {
setXPriv('');
}, [xPub]);
const debouncedXPub = useDebouncedCallback(() => {
if (form.getValues('xPub')?.length === 0) {
form.clearErrors();
return;
}
form.trigger('xPub');
}, 700);

const handeDialogToggle = () => {
setIsOpen((prev) => !prev);
setXPriv('');
setXPub('');
form.reset();
};

const onSubmit = async () => {
if (!xPub) {
return;
const onXPrivChange = (e: React.ChangeEvent<HTMLInputElement>) => {
form.setValue('xPriv', e.target.value);
debouncedXPriv();
};

const onXPubChange = (e: React.ChangeEvent<HTMLInputElement>) => {
form.setValue('xPub', e.target.value);
form.resetField('xPriv');
debouncedXPub();
};

const onXPubClear = () => {
form.resetField('xPub');
if (xPubRef.current) {
xPubRef.current?.focus();
}
try {
HD.fromString(xPub);
await mutation.mutateAsync(xPub);
toast.success('Added xPub');
setXPriv('');
setXPub('');
} catch (error) {
toast.error('Unable to add xPub');
errorWrapper(error);
};

const onXPrivClear = () => {
form.resetField('xPriv');
if (xPrivRef.current) {
xPrivRef.current?.focus();
}
};

Expand All @@ -99,29 +154,57 @@ export const AddXpubDialog = ({ className }: AddXpubDialogProps) => {
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add xPub</DialogTitle>
<DialogDescription>Get xpub from xpriv</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-[1fr_10fr] items-center gap-4">
<Label htmlFor="xPriv" className="text-right">
xPriv
</Label>
<Input id="xPriv" placeholder="xprv..." value={xPriv} onChange={handleXPrivChange} />
</div>
<div className="flex justify-center text-gray-400 text-xs">Or put xpub directly</div>

<div className="grid grid-cols-[1fr_10fr] items-center gap-4">
<Label htmlFor="xPub" className="text-right">
xPub
</Label>
<Input id="xPub" value={xPub} onChange={handleXPubChange} placeholder="xpub..." />
</div>
</div>
<DialogFooter>
<Button onClick={onSubmit}>Add xPub</Button>
</DialogFooter>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add xPub</DialogTitle>
<DialogDescription>Get xpub from xpriv</DialogDescription>
</DialogHeader>
<FormField
render={({ field }) => (
<FormItem className="relative">
<FormLabel>xPriv</FormLabel>
<FormControl>
<Input
placeholder="xprv..."
autoComplete="off"
className="pr-12"
{...field}
ref={xPrivRef}
onChange={onXPrivChange}
/>
</FormControl>
<CircleX size={16} className="absolute top-9 right-4 cursor-pointer" onClick={onXPrivClear} />
<FormMessage />
</FormItem>
)}
name="xPriv"
/>
<FormField
render={({ field }) => (
<FormItem className="mt-2 relative">
<FormLabel>xPub</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="xpub..."
className="pr-12"
{...field}
ref={xPubRef}
onChange={(e) => onXPubChange(e)}
/>
</FormControl>
<CircleX size={16} className="absolute top-9 right-4 cursor-pointer" onClick={onXPubClear} />
<FormMessage />
</FormItem>
)}
name="xPub"
/>
<DialogFooter className="mt-4">
<Button type="submit">Add xPub</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4948,10 +4948,10 @@ use-callback-ref@^1.3.0:
dependencies:
tslib "^2.0.0"

use-debounce@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.1.tgz#adc42e7f7b08a237f5bbf0ea6cadee5813863cdc"
integrity sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==
use-debounce@^10.0.3:
version "10.0.3"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.3.tgz#636094a37f7aa2bcc77b26b961481a0b571bf7ea"
integrity sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==

use-sidecar@^1.1.2:
version "1.1.2"
Expand Down

0 comments on commit 0db38ce

Please sign in to comment.