forked from cerc-io/snowballtools-base
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>
691 lines
22 KiB
TypeScript
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;
|