snowballtools-base/packages/frontend/src/components/projects/create/Configure.tsx
Nabarun dd1d747b60 Implement feature to add custom network config to embedded wallet (#59)
Part of https://www.notion.so/Laconic-Mainnet-Plan-1eca6b22d47280569cd0d1e6d711d949

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Co-authored-by: Prathamesh Musale <prathamesh.musale0@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#59
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2025-06-24 17:06:51 +00:00

691 lines
22 KiB
TypeScript

import { useCallback, useState, useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { FormProvider, FieldValues } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMediaQuery } from 'usehooks-ts';
import {
AddEnvironmentVariableInput,
AuctionParams,
Deployer,
} from 'gql-client';
import { BigNumber } from 'ethers';
import { Select, MenuItem, FormControl, FormHelperText } from '@mui/material';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button';
import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext';
import ApproveTransactionModal from './ApproveTransactionModal';
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
import { EnvironmentVariablesFormValues } from 'types/types';
import {
VITE_WALLET_IFRAME_URL,
} from 'utils/constants';
import CheckBalanceIframe from './CheckBalanceIframe';
import { useAddNetwork } from '../../../hooks/useAddNetwork';
type ConfigureDeploymentFormValues = {
option: string;
lrn?: string;
numProviders?: string;
maxPrice?: string;
};
type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000';
const TX_APPROVAL_TIMEOUT_MS = 60000;
const Configure = () => {
const [isLoading, setIsLoading] = useState(false);
const [deployers, setDeployers] = useState<Deployer[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>();
const [selectedDeployer, setSelectedDeployer] = useState<Deployer>();
const [isPaymentLoading, setIsPaymentLoading] = useState(false);
const [isPaymentDone, setIsPaymentDone] = useState(false);
const [isFrameVisible, setIsFrameVisible] = useState(false);
const [isAccountsDataReceived, setIsAccountsDataReceived] = useState(false);
const [balanceMessage, setBalanceMessage] = useState<string>();
const [isBalanceSufficient, setIsBalanceSufficient] = useState<boolean>();
const [searchParams] = useSearchParams();
const templateId = searchParams.get('templateId');
const queryParams = new URLSearchParams(location.search);
const owner = queryParams.get('owner');
const name = queryParams.get('name');
const defaultBranch = queryParams.get('defaultBranch');
const fullName = queryParams.get('fullName');
const orgSlug = queryParams.get('orgSlug');
const templateOwner = queryParams.get('templateOwner');
const templateRepo = queryParams.get('templateRepo');
const isPrivate = queryParams.get('isPrivate') === 'true';
const navigate = useNavigate();
const { toast, dismiss } = useToast();
const client = useGQLClient();
const { networkData } = useAddNetwork()
const methods = useForm<ConfigureFormValues>({
defaultValues: {
option: 'Auction',
maxPrice: DEFAULT_MAX_PRICE,
lrn: '',
numProviders: '1',
variables: [],
},
});
const selectedOption = methods.watch('option');
const selectedNumProviders = methods.watch('numProviders') ?? '1';
const selectedMaxPrice = methods.watch('maxPrice') ?? DEFAULT_MAX_PRICE;
const isTabletView = useMediaQuery('(min-width: 720px)'); // md:
const buttonSize = isTabletView ? { size: 'lg' as const } : {};
const amountToBePaid = useMemo(() => {
let amount: string;
if (selectedOption === 'LRN') {
amount = selectedDeployer?.minimumPayment?.replace(/\D/g, '') ?? '0';
} else {
if (!selectedNumProviders || !selectedMaxPrice) {
return '';
}
const bigMaxPrice = BigNumber.from(selectedMaxPrice);
amount = bigMaxPrice.mul(selectedNumProviders).toString();
}
return amount;
}, [
selectedOption,
selectedDeployer?.minimumPayment,
selectedMaxPrice,
selectedNumProviders,
]);
const createProject = async (
data: FieldValues,
envVariables: AddEnvironmentVariableInput[],
senderAddress: string,
txHash: string,
): Promise<string> => {
setIsLoading(true);
let projectId: string | null = null;
try {
let lrn: string | undefined;
let auctionParams: AuctionParams | undefined;
if (data.option === 'LRN') {
lrn = data.lrn;
} else if (data.option === 'Auction') {
auctionParams = {
numProviders: Number(data.numProviders!),
maxPrice: data.maxPrice!.toString(),
};
}
if (templateId) {
const projectData: any = {
templateOwner,
templateRepo,
owner,
name,
isPrivate,
paymentAddress: senderAddress,
txHash,
};
const { addProjectFromTemplate } = await client.addProjectFromTemplate(
orgSlug!,
projectData,
lrn,
auctionParams,
envVariables,
);
projectId = addProjectFromTemplate.id;
} else {
const { addProject } = await client.addProject(
orgSlug!,
{
name: `${owner}-${name}`,
prodBranch: defaultBranch!,
repository: fullName!,
template: 'webapp',
paymentAddress: senderAddress,
txHash,
},
lrn,
auctionParams,
envVariables,
);
projectId = addProject.id;
}
} catch (error) {
console.error('Error creating project:', error);
toast({
id: 'error-creating-project',
title: 'Error creating project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
if (projectId) {
return projectId;
} else {
throw new Error('Project creation failed');
}
};
const verifyTx = async (
senderAddress: string,
txHash: string,
amount: string,
): Promise<boolean> => {
const isValid = await client.verifyTx(
txHash,
`${amount.toString()}alnt`,
senderAddress,
);
return isValid;
};
const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => {
try {
const deployerLrn = createFormData.lrn;
const deployer = deployers.find(
(deployer) => deployer.deployerLrn === deployerLrn,
);
let senderAddress: string;
let txHash: string | null = null;
if (createFormData.option === 'LRN' && !deployer?.minimumPayment) {
toast({
id: 'no-payment-required',
title: 'No payment required. Deploying app...',
variant: 'info',
onDismiss: dismiss,
});
txHash = '';
senderAddress = '';
} else {
if (!selectedAccount) return;
senderAddress = selectedAccount;
txHash = await cosmosSendTokensHandler(senderAddress, amountToBePaid);
if (!txHash) {
toast({
id: 'unsuccessful-tx',
title: 'Transaction rejected',
variant: 'error',
onDismiss: dismiss,
});
setIsFrameVisible(false);
setIsPaymentLoading(false);
throw new Error('Transaction rejected');
}
// Validate transaction hash
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amountToBePaid,
);
setIsPaymentLoading(false);
if (isTxHashValid) {
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
setIsPaymentDone(true);
} else {
toast({
id: 'invalid-tx-hash',
title: 'Transaction validation failed',
variant: 'error',
onDismiss: dismiss,
});
throw new Error('Transaction validation failed');
}
}
const environmentVariables = createFormData.variables.map(
(variable: any) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
},
);
const projectId = await createProject(
createFormData,
environmentVariables,
senderAddress,
txHash!,
);
await client.getEnvironmentVariables(projectId);
if (templateId) {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/template/deploy?projectId=${projectId}&templateId=${templateId}`,
);
} else {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/deploy?projectId=${projectId}`,
);
}
} catch (error: any) {
toast({
id: 'error-deploying-app',
title: 'Error deploying app',
variant: 'error',
onDismiss: dismiss,
});
throw new Error(error);
}
},
[client, createProject, dismiss, toast, amountToBePaid],
);
const fetchDeployers = useCallback(async () => {
const res = await client.getDeployers();
setDeployers(res.deployers);
}, [client]);
const onDeployerChange = useCallback(
(selectedLrn: string) => {
const deployer = deployers.find((d) => d.deployerLrn === selectedLrn);
setSelectedDeployer(deployer);
},
[deployers],
);
const cosmosSendTokensHandler = useCallback(
async (selectedAccount: string, amount: string) => {
if (!selectedAccount) {
throw new Error('Account not selected');
}
const senderAddress = selectedAccount;
const snowballAddress = await client.getAddress();
let timeoutId;
try {
setIsPaymentDone(false);
setIsPaymentLoading(true);
await requestTx(senderAddress, snowballAddress, amount);
const txHash = await new Promise<string | null>((resolve, reject) => {
// Call cleanup method only if appropriate event type is recieved
const cleanup = () => {
setIsFrameVisible(false);
window.removeEventListener('message', handleTxStatus);
};
const handleTxStatus = async (event: MessageEvent) => {
if (event.origin !== VITE_WALLET_IFRAME_URL) return;
if (event.data.type === 'TRANSACTION_RESPONSE') {
const txResponse = event.data.data;
resolve(txResponse);
cleanup();
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
reject(new Error('Transaction failed'));
toast({
id: 'error-transaction',
title: 'Error during transaction',
variant: 'error',
onDismiss: dismiss,
});
cleanup();
}
};
window.addEventListener('message', handleTxStatus);
// Set a timeout, consider unsuccessful after 1 min
timeoutId = setTimeout(() => {
reject(new Error('Transaction timeout'));
window.removeEventListener('message', handleTxStatus);
toast({
id: 'transaction-timeout',
title: 'The transaction request timed out. Please try again',
variant: 'error',
onDismiss: dismiss,
});
setIsFrameVisible(false);
setIsPaymentLoading(false);
}, TX_APPROVAL_TIMEOUT_MS);
});
return txHash;
} catch (error) {
console.error('Error in transaction:', error);
throw new Error('Error in transaction');
} finally {
clearTimeout(timeoutId);
}
},
[client, dismiss, toast],
);
const requestTx = async (
sender: string,
recipient: string,
amount: string,
) => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement;
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded');
throw new Error('Iframe not found or not loaded');
}
iframe.contentWindow.postMessage(
{
type: 'REQUEST_TX',
chainId: networkData?.chainId,
fromAddress: sender,
toAddress: recipient,
amount,
},
VITE_WALLET_IFRAME_URL,
);
setIsFrameVisible(true);
};
useEffect(() => {
fetchDeployers();
}, []);
useEffect(() => {
if (isBalanceSufficient) {
setBalanceMessage(undefined);
}
}, [isBalanceSufficient]);
return (
<div className="space-y-7 px-4 py-6">
<div className="flex justify-between mb-6">
<div className="space-y-1.5">
<Heading as="h4" className="md:text-lg font-medium">
Configure deployment
</Heading>
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em dark:text-foreground-secondaryu"
>
The app can be deployed by setting the deployer LRN for a single
deployment or by creating a deployer auction for multiple
deployments
</Heading>
</div>
</div>
<div className="flex flex-col gap-6 lg:gap-8 w-full">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleFormSubmit)}>
<div className="flex flex-col justify-start gap-4 mb-6">
<Controller
name="option"
control={methods.control}
render={({ field: { value, onChange } }) => (
<Select
value={value}
onChange={(event) => onChange(event.target.value)}
size="small"
displayEmpty
className="dark:bg-overlay2 dark:text-foreground"
sx={{
fontFamily: 'inherit',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e0e0e0',
borderRadius: '8px',
},
}}
>
<MenuItem value="Auction">Create Auction</MenuItem>
<MenuItem value="LRN">Deployer LRN</MenuItem>
</Select>
)}
/>
</div>
{selectedOption === 'LRN' && (
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em dark:text-foreground-secondary"
>
The app will be deployed by the configured deployer
</Heading>
<Controller
name="lrn"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange }, fieldState }) => (
<FormControl fullWidth error={Boolean(fieldState.error)}>
<span className="text-sm dark:text-foreground text-elements-high-em dark:text-foreground mb-4">
Select deployer LRN
</span>
<Select
value={value}
onChange={(event) => {
onChange(event.target.value);
onDeployerChange(event.target.value);
}}
displayEmpty
size="small"
className="dark:bg-overlay2 dark:text-foreground"
>
{deployers.map((deployer) => (
<MenuItem
key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
{`${deployer.deployerLrn} ${deployer.minimumPayment ? `(${deployer.minimumPayment})` : ''}`}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText>
{fieldState.error.message}
</FormHelperText>
)}
</FormControl>
)}
/>
</div>
)}
{selectedOption === 'Auction' && (
<>
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em dark:text-foreground-secondary"
>
Set the number of deployers and maximum price for each
deployment
</Heading>
<span className="text-sm text-elements-high-em dark:text-foreground">
Number of Deployers
</span>
<Controller
name="numProviders"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input
type="number"
value={value}
onChange={(e) => onChange(e)}
min={1}
/>
)}
/>
</div>
<div className="flex flex-col justify-start gap-4 mb-6">
<span className="text-sm text-elements-high-em dark:text-foreground">
Maximum Price (alnt)
</span>
<Controller
name="maxPrice"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input type="number" value={value} onChange={onChange} min={1} />
)}
/>
</div>
</>
)}
<Heading as="h4" className="md:text-lg font-medium mb-3">
Environment Variables
</Heading>
<div className="p-4 bg-slate-100 dark:bg-overlay3 rounded-lg mb-6">
<EnvironmentVariablesForm />
</div>
{selectedOption === 'LRN' && !selectedDeployer?.minimumPayment ? (
<div>
<Button
{...buttonSize}
type="submit"
disabled={isLoading || !selectedDeployer}
rightIcon={
isLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{isLoading ? 'Deploying' : 'Deploy'}
</Button>
</div>
) : (
<div className="flex gap-4">
<Button
{...buttonSize}
type="submit"
shape="default"
disabled={
isLoading ||
isPaymentLoading ||
!selectedAccount ||
!isBalanceSufficient ||
amountToBePaid === '' ||
selectedNumProviders === ''
}
rightIcon={
isLoading || isPaymentLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{!isPaymentDone
? isPaymentLoading
? 'Transaction Requested'
: 'Pay and Deploy'
: isLoading
? 'Deploying'
: 'Deploy'}
</Button>
{isAccountsDataReceived && isBalanceSufficient !== undefined ? (
!selectedAccount || !isBalanceSufficient ? (
<div className="flex items-center gap-4">
<Button
{...buttonSize}
shape="default"
onClick={(e: any) => {
e.preventDefault();
setBalanceMessage('Waiting for payment');
window.open(
'https://store.laconic.com',
'_blank',
'noopener,noreferrer',
);
}}
>
Buy prepaid service
</Button>
<p className="text-gray-700 dark:text-gray-300">
{balanceMessage !== undefined ? (
<div className="flex items-center gap-2 text-white">
<LoadingIcon className="animate-spin w-5 h-5" />
<p>{balanceMessage}</p>
</div>
) : !selectedAccount ? (
'No accounts found. Create a wallet.'
) : (
'Insufficient funds.'
)}
</p>
</div>
) : null
) : null}
</div>
)}
</form>
</FormProvider>
<ApproveTransactionModal
setAccount={setSelectedAccount}
setIsDataReceived={setIsAccountsDataReceived}
isVisible={isFrameVisible}
/>
<CheckBalanceIframe
onBalanceChange={setIsBalanceSufficient}
amount={amountToBePaid}
isPollingEnabled={true}
/>
</div>
</div>
);
};
export default Configure;