Integrate wallet IFrame for payments #42

Merged
nabarun merged 27 commits from iv-integrate-frame into main 2024-11-13 13:32:28 +00:00
8 changed files with 191 additions and 389 deletions
Showing only changes of commit 97fa1988fb - Show all commits

View File

@ -15,3 +15,4 @@ VITE_PASSKEY_WALLET_RPID=
VITE_TURNKEY_API_BASE_URL= VITE_TURNKEY_API_BASE_URL=
VITE_LACONICD_CHAIN_ID= VITE_LACONICD_CHAIN_ID=
VITE_IFRAME_ORIGIN_URL=

View File

@ -0,0 +1,49 @@
import { Select, Option } from '@snowballtools/material-tailwind-react-fork';
const AccountsDropdown = ({
accounts,
onAccountChange,
}: {
accounts: string[];
onAccountChange: (selectedAccount: string) => void;
}) => {
return (
<div className="p-6 bg-slate-100 dark:bg-overlay3 rounded-lg mb-6 shadow-md">
{!accounts.length ? (
<div className="text-center">
<p className="text-gray-700 dark:text-gray-300 mb-4">
No accounts found. Please visit{' '}
<a
href="https://store.laconic.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline dark:text-blue-400"
>
store.laconic.com
</a>{' '}
to create a wallet.
</p>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0]}
onChange={(value) => value && onAccountChange(value)}
className="dark:bg-overlay2 dark:text-foreground"
aria-label="Wallet Account Selector"
>
{accounts.map((account, index) => (
<Option key={index} value={account}>
{account}
</Option>
))}
</Select>
</div>
)}
</div>
);
};
export default AccountsDropdown;

View File

@ -20,10 +20,11 @@ import { Button } from '../../shared/Button';
import { Input } from 'components/shared/Input'; import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast'; import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext'; import { useGQLClient } from '../../../context/GQLClientContext';
import IFrame from './IFrame'; import IFrameModal from './IFrameModal';
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm'; import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
import { EnvironmentVariablesFormValues } from 'types/types'; import { EnvironmentVariablesFormValues } from 'types/types';
import { VITE_LACONICD_CHAIN_ID } from 'utils/constants'; import { VITE_LACONICD_CHAIN_ID, VITE_IFRAME_ORIGIN_URL } from 'utils/constants';
import AccountsDropdown from './AccountsDropdown';
type ConfigureDeploymentFormValues = { type ConfigureDeploymentFormValues = {
option: string; option: string;
@ -36,6 +37,7 @@ type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues; EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000'; const DEFAULT_MAX_PRICE = '10000';
const TX_APPROVAL_TIMEOUT = 60000;
const Configure = () => { const Configure = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -175,7 +177,6 @@ const Configure = () => {
const handleFormSubmit = useCallback( const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => { async (createFormData: FieldValues) => {
try { try {
setIsFrameVisible(true);
const deployerLrn = createFormData.lrn; const deployerLrn = createFormData.lrn;
const deployer = deployers.find( const deployer = deployers.find(
(deployer) => deployer.deployerLrn === deployerLrn, (deployer) => deployer.deployerLrn === deployerLrn,
@ -213,6 +214,33 @@ const Configure = () => {
senderAddress, senderAddress,
amountToBePaid, amountToBePaid,
); );
if (!txHash) {
return;
}
// Validate transaction hash
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amount,
);
if (isTxHashValid) {
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
} else {
toast({
id: 'invalid-tx-hash',
title: 'Transaction validation failed',
variant: 'error',
onDismiss: dismiss,
});
}
} }
const environmentVariables = createFormData.variables.map( const environmentVariables = createFormData.variables.map(
@ -300,48 +328,16 @@ const Configure = () => {
setIsPaymentDone(false); setIsPaymentDone(false);
setIsPaymentLoading(true); setIsPaymentLoading(true);
toast({
id: 'sending-payment-request',
title: 'Check and approve payment request',
variant: 'loading',
onDismiss: dismiss,
});
await requestTx(senderAddress, snowballAddress, amount); await requestTx(senderAddress, snowballAddress, amount);
const txHash = await new Promise<string>((resolve, reject) => { const txHash = await new Promise<string>((resolve, reject) => {
const handleTxStatus = async (event: MessageEvent) => { const handleTxStatus = async (event: MessageEvent) => {
if (event.origin !== 'http://localhost:3001') return; if (event.origin !== VITE_IFRAME_ORIGIN_URL) return;
if (event.data.type === 'TRANSACTION_SUCCESS') { if (event.data.type === 'TRANSACTION_SUCCESS') {
const txResponse = event.data.data; const txResponse = event.data.data;
setIsFrameVisible(false);
// Validate transaction hash
const isTxHashValid = await verifyTx(
senderAddress,
txResponse,
amount,
);
if (isTxHashValid) {
resolve(txResponse); resolve(txResponse);
toast({ setIsFrameVisible(false);
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
} else {
reject(new Error('Invalid transaction hash'));
toast({
id: 'invalid-tx-hash',
title: 'Transaction validation failed',
variant: 'error',
onDismiss: dismiss,
});
}
} else if (event.data.type === 'ERROR') { } else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message); console.error('Error from wallet:', event.data.message);
reject(new Error('Transaction failed')); reject(new Error('Transaction failed'));
@ -362,7 +358,13 @@ const Configure = () => {
setTimeout(() => { setTimeout(() => {
reject(new Error('Transaction timeout')); reject(new Error('Transaction timeout'));
window.removeEventListener('message', handleTxStatus); window.removeEventListener('message', handleTxStatus);
}, 60000); toast({
id: 'transaction-timeout',
title: 'The transaction request timed out. Please try again',
variant: 'error',
onDismiss: dismiss,
});
}, TX_APPROVAL_TIMEOUT);
}); });
return txHash; return txHash;
} catch (error) { } catch (error) {
@ -391,8 +393,10 @@ const Configure = () => {
toAddress: recipient, toAddress: recipient,
amount, amount,
}, },
'http://localhost:3001' VITE_IFRAME_ORIGIN_URL
); );
setIsFrameVisible(true);
}; };
useEffect(() => { useEffect(() => {
@ -561,10 +565,9 @@ const Configure = () => {
</div> </div>
) : ( ) : (
<> <>
<IFrame <AccountsDropdown accounts={accounts} onAccountChange={onAccountChange} />
accounts={accounts} <IFrameModal
setAccounts={setAccounts} setAccounts={setAccounts}
onAccountChange={onAccountChange}
isVisible={isFrameVisible} isVisible={isFrameVisible}
toggleModal={toggleModal} toggleModal={toggleModal}
/> />

View File

@ -1,124 +0,0 @@
import { useCallback, useEffect } from 'react';
import { Select, Option } from '@snowballtools/material-tailwind-react-fork';
import { Box, Modal } from '@mui/material';
import { VITE_LACONICD_CHAIN_ID } from 'utils/constants';
const IFrame = ({
accounts,
setAccounts,
onAccountChange,
isVisible,
toggleModal,
}: {
accounts: string[];
setAccounts: (accounts: string[]) => void;
onAccountChange: (selectedAccount: string) => void;
isVisible: boolean;
toggleModal: () => void;
}) => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// TODO: Use env for origin URL
if (event.origin !== 'http://localhost:3001') return;
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
setAccounts(event.data.data);
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
const getDataFromWallet = useCallback(() => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement;
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded');
return;
}
iframe.contentWindow.postMessage(
{
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: VITE_LACONICD_CHAIN_ID,
},
'http://localhost:3001'
);
}, []);
return (
<div className="p-6 bg-slate-100 dark:bg-overlay3 rounded-lg mb-6 shadow-md">
{!accounts.length ? (
<div className="text-center">
<p className="text-gray-700 dark:text-gray-300 mb-4">
No accounts found. Please visit{' '}
<a
href="https://store.laconic.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline dark:text-blue-400"
>
store.laconic.com
</a>{' '}
to create a wallet.
</p>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0]}
onChange={(value) => value && onAccountChange(value)}
className="dark:bg-overlay2 dark:text-foreground"
aria-label="Wallet Account Selector"
>
{accounts.map((account, index) => (
<Option key={index} value={account}>
{account}
</Option>
))}
</Select>
</div>
)}
<Modal open={isVisible} onClose={toggleModal} keepMounted>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90%',
maxWidth: '1200px',
height: '600px',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: 24,
borderRadius: '8px',
outline: 'none',
bgcolor: 'background.paper',
}}
>
<iframe
onLoad={getDataFromWallet}
id="walletIframe"
src="http://localhost:3001/WalletEmbed"
width="100%"
height="100%"
sandbox="allow-scripts allow-same-origin"
className="border rounded-md shadow-sm"
></iframe>
</Box>
</Modal>
</div>
);
};
export default IFrame;

View File

@ -0,0 +1,85 @@
import { useCallback, useEffect } from 'react';
import { Box, Modal } from '@mui/material';
import { VITE_LACONICD_CHAIN_ID, VITE_IFRAME_ORIGIN_URL } from 'utils/constants';
const IFrameModal = ({
setAccounts,
isVisible,
toggleModal,
}: {
setAccounts: (accounts: string[]) => void;
isVisible: boolean;
toggleModal: () => void;
}) => {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// TODO: Use env for origin URL
if (event.origin !== VITE_IFRAME_ORIGIN_URL) return;
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
setAccounts(event.data.data);
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
const getDataFromWallet = useCallback(() => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement;
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded');
return;
}
iframe.contentWindow.postMessage(
{
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: VITE_LACONICD_CHAIN_ID,
},
VITE_IFRAME_ORIGIN_URL
);
}, []);
return (
<Modal open={isVisible} onClose={toggleModal} keepMounted>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90%',
maxWidth: '1200px',
height: '600px',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: 24,
borderRadius: '8px',
outline: 'none',
bgcolor: 'background.paper',
}}
>
<iframe
onLoad={getDataFromWallet}
id="walletIframe"
src={`${VITE_IFRAME_ORIGIN_URL}/WalletEmbed`}
width="100%"
height="100%"
sandbox="allow-scripts allow-same-origin"
className="border rounded-md shadow-sm"
></iframe>
</Box>
</Modal>
)
};
export default IFrameModal;

View File

@ -1,210 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import SignClient from '@walletconnect/sign-client';
import { getSdkError } from '@walletconnect/utils';
import { SessionTypes } from '@walletconnect/types';
import { walletConnectModal } from '../utils/web3modal';
import {
VITE_LACONICD_CHAIN_ID,
VITE_WALLET_CONNECT_ID,
} from 'utils/constants';
interface ClientInterface {
signClient: SignClient | undefined;
session: SessionTypes.Struct | undefined;
loadingSession: boolean;
onConnect: () => Promise<void>;
onDisconnect: () => Promise<void>;
onSessionDelete: () => void;
accounts: { address: string }[];
}
const ClientContext = createContext({} as ClientInterface);
export const useWalletConnectClient = () => {
return useContext(ClientContext);
};
export const WalletConnectClientProvider = ({
children,
}: {
children: JSX.Element;
}) => {
const [signClient, setSignClient] = useState<SignClient>();
const [session, setSession] = useState<SessionTypes.Struct>();
const [loadingSession, setLoadingSession] = useState(true);
const [accounts, setAccounts] = useState<{ address: string }[]>([]);
const isSignClientInitializing = useRef<boolean>(false);
const onSessionConnect = useCallback(async (session: SessionTypes.Struct) => {
setSession(session);
}, []);
const subscribeToEvents = useCallback(
async (client: SignClient) => {
client.on('session_update', ({ topic, params }) => {
const { namespaces } = params;
const currentSession = client.session.get(topic);
const updatedSession = { ...currentSession, namespaces };
setSession(updatedSession);
});
},
[setSession],
);
const onConnect = async () => {
const proposalNamespace = {
cosmos: {
methods: ['cosmos_sendTokens'],
chains: [`cosmos:${VITE_LACONICD_CHAIN_ID}`],
events: [],
},
};
try {
const { uri, approval } = await signClient!.connect({
requiredNamespaces: proposalNamespace,
});
if (uri) {
walletConnectModal.openModal({ uri });
const session = await approval();
onSessionConnect(session);
walletConnectModal.closeModal();
}
} catch (e) {
console.error(e);
}
};
const onDisconnect = useCallback(async () => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session === 'undefined') {
throw new Error('Session is not connected');
}
await signClient.disconnect({
topic: session.topic,
reason: getSdkError('USER_DISCONNECTED'),
});
onSessionDelete();
}, [signClient, session]);
const onSessionDelete = () => {
setAccounts([]);
setSession(undefined);
};
const checkPersistedState = useCallback(
async (signClient: SignClient) => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session !== 'undefined') return;
if (signClient.session.length) {
const lastKeyIndex = signClient.session.keys.length - 1;
const previousSsession = signClient.session.get(
signClient.session.keys[lastKeyIndex],
);
await onSessionConnect(previousSsession);
return previousSsession;
}
},
[session, onSessionConnect],
);
const createClient = useCallback(async () => {
isSignClientInitializing.current = true;
try {
const signClient = await SignClient.init({
projectId: VITE_WALLET_CONNECT_ID,
metadata: {
name: 'Deploy App',
description: '',
url: window.location.href,
icons: ['https://avatars.githubusercontent.com/u/92608123'],
},
});
setSignClient(signClient);
await checkPersistedState(signClient);
await subscribeToEvents(signClient);
setLoadingSession(false);
} catch (e) {
console.error('error in createClient', e);
}
isSignClientInitializing.current = false;
}, [setSignClient, checkPersistedState, subscribeToEvents]);
useEffect(() => {
if (!signClient && !isSignClientInitializing.current) {
createClient();
}
}, [signClient, createClient]);
useEffect(() => {
const populateAccounts = async () => {
if (!session) {
return;
}
if (!session.namespaces['cosmos']) {
console.log('Accounts for cosmos namespace not found');
return;
}
const cosmosAddresses = session.namespaces['cosmos'].accounts;
const cosmosAccounts = cosmosAddresses.map((address) => ({
address,
}));
const allAccounts = cosmosAccounts;
setAccounts(allAccounts);
};
populateAccounts();
}, [session]);
useEffect(() => {
if (!signClient) {
return;
}
signClient.on('session_delete', onSessionDelete);
return () => {
signClient.off('session_delete', onSessionDelete);
};
});
return (
<ClientContext.Provider
value={{
signClient,
onConnect,
onDisconnect,
onSessionDelete,
loadingSession,
session,
accounts,
}}
>
{children}
</ClientContext.Provider>
);
};

View File

@ -4,10 +4,9 @@ import assert from 'assert';
import { GQLClient } from 'gql-client'; import { GQLClient } from 'gql-client';
import { ThemeProvider } from '@snowballtools/material-tailwind-react-fork'; import { ThemeProvider } from '@snowballtools/material-tailwind-react-fork';
import './index.css';
import '@fontsource/inter'; import '@fontsource/inter';
import '@fontsource-variable/jetbrains-mono'; import '@fontsource-variable/jetbrains-mono';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import { GQLClientProvider } from './context/GQLClientContext'; import { GQLClientProvider } from './context/GQLClientContext';
@ -16,7 +15,7 @@ import { Toaster } from 'components/shared/Toast';
import { LogErrorBoundary } from 'utils/log-error'; import { LogErrorBoundary } from 'utils/log-error';
import { BASE_URL } from 'utils/constants'; import { BASE_URL } from 'utils/constants';
import Web3ModalProvider from './context/Web3Provider'; import Web3ModalProvider from './context/Web3Provider';
import { WalletConnectClientProvider } from 'context/WalletConnectContext'; import './index.css';
console.log(`v-0.0.9`); console.log(`v-0.0.9`);
@ -32,7 +31,6 @@ const gqlClient = new GQLClient({ gqlEndpoint });
root.render( root.render(
<LogErrorBoundary> <LogErrorBoundary>
<React.StrictMode> <React.StrictMode>
<WalletConnectClientProvider>
<ThemeProvider> <ThemeProvider>
<Web3ModalProvider> <Web3ModalProvider>
<GQLClientProvider client={gqlClient}> <GQLClientProvider client={gqlClient}>
@ -41,7 +39,6 @@ root.render(
</GQLClientProvider> </GQLClientProvider>
</Web3ModalProvider> </Web3ModalProvider>
</ThemeProvider> </ThemeProvider>
</WalletConnectClientProvider>
</React.StrictMode> </React.StrictMode>
</LogErrorBoundary>, </LogErrorBoundary>,
); );

View File

@ -12,3 +12,4 @@ export const VITE_WALLET_CONNECT_ID = import.meta.env.VITE_WALLET_CONNECT_ID;
export const VITE_BUGSNAG_API_KEY = import.meta.env.VITE_BUGSNAG_API_KEY; export const VITE_BUGSNAG_API_KEY = import.meta.env.VITE_BUGSNAG_API_KEY;
export const VITE_LIT_RELAY_API_KEY = import.meta.env.VITE_LIT_RELAY_API_KEY; export const VITE_LIT_RELAY_API_KEY = import.meta.env.VITE_LIT_RELAY_API_KEY;
export const VITE_LACONICD_CHAIN_ID = import.meta.env.VITE_LACONICD_CHAIN_ID; export const VITE_LACONICD_CHAIN_ID = import.meta.env.VITE_LACONICD_CHAIN_ID;
export const VITE_IFRAME_ORIGIN_URL = import.meta.env.VITE_IFRAME_ORIGIN_URL;