From 63969ae25a76d9a446e9a1021b01d22f2834fccd Mon Sep 17 00:00:00 2001 From: nabarun Date: Mon, 28 Oct 2024 09:46:18 +0000 Subject: [PATCH] Implement payments for app deployments (#17) Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75) - Implement funtionality to pay for deployments by connecting wallet using `WalletConnect` ![image](/attachments/842e33e8-7de6-4d91-9008-1c67a259b586) ![image](/attachments/94b2fe39-f753-4e99-a8c2-bda4c0b84897) Co-authored-by: IshaVenikar Co-authored-by: Shreerang Kale Reviewed-on: https://git.vdb.to/cerc-io/snowballtools-base/pulls/17 --- build-webapp.sh | 1 + .../backend/environments/local.toml.example | 3 - packages/backend/src/config.ts | 5 - packages/backend/src/database.ts | 2 +- packages/backend/src/entity/Deployer.ts | 3 + packages/backend/src/entity/Project.ts | 6 + packages/backend/src/index.ts | 2 +- packages/backend/src/registry.ts | 33 +- packages/backend/src/resolvers.ts | 17 +- packages/backend/src/schema.gql | 9 + packages/backend/src/service.ts | 64 +- packages/backend/src/types.ts | 3 + packages/deployer/deploy-frontend.sh | 1 + packages/deployer/deploy-frontend.staging.sh | 1 + packages/frontend/.env.example | 2 + packages/frontend/package.json | 4 - .../frontend/src/components/StopWatch.tsx | 6 +- .../components/projects/create/Configure.tsx | 250 ++- .../projects/create/ConnectWallet.tsx | 46 + .../deployments/DeploymentDetailsCard.tsx | 4 +- .../src/context/WalletConnectContext.tsx | 210 +++ packages/frontend/src/index.tsx | 19 +- .../frontend/src/pages/auth/CreatePasskey.tsx | 83 - .../frontend/src/pages/components/modals.tsx | 1 + .../projects/create/template/index.tsx | 10 - .../frontend/src/stories/MockStoriesData.ts | 4 + packages/frontend/src/utils/constants.ts | 1 + packages/frontend/src/utils/siwe.ts | 48 - packages/frontend/src/utils/use-snowball.ts | 33 - packages/frontend/src/utils/web3modal.ts | 8 + packages/gql-client/src/client.ts | 21 + packages/gql-client/src/queries.ts | 23 + packages/gql-client/src/types.ts | 7 + yarn.lock | 1361 +---------------- 34 files changed, 752 insertions(+), 1539 deletions(-) create mode 100644 packages/frontend/src/components/projects/create/ConnectWallet.tsx create mode 100644 packages/frontend/src/context/WalletConnectContext.tsx delete mode 100644 packages/frontend/src/pages/auth/CreatePasskey.tsx delete mode 100644 packages/frontend/src/utils/siwe.ts delete mode 100644 packages/frontend/src/utils/use-snowball.ts create mode 100644 packages/frontend/src/utils/web3modal.ts diff --git a/build-webapp.sh b/build-webapp.sh index 4f411db9..9608dbf2 100755 --- a/build-webapp.sh +++ b/build-webapp.sh @@ -15,6 +15,7 @@ VITE_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_github_clientid' VITE_GITHUB_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_pwa_templaterepo' VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo' VITE_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_wallet_connect_id' +VITE_LACONICD_CHAIN_ID = 'LACONIC_HOSTED_CONFIG_laconicd_chain_id' VITE_LIT_RELAY_API_KEY = 'LACONIC_HOSTED_CONFIG_lit_relay_api_key' VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key' VITE_PASSKEY_WALLET_RPID = 'LACONIC_HOSTED_CONFIG_passkey_wallet_rpid' diff --git a/packages/backend/environments/local.toml.example b/packages/backend/environments/local.toml.example index efebff59..233d495c 100644 --- a/packages/backend/environments/local.toml.example +++ b/packages/backend/environments/local.toml.example @@ -41,6 +41,3 @@ revealFee = "100000" revealsDuration = "120s" denom = "alnt" - -[misc] - projectDomain = "apps.snowballtools.com" diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 38285f72..a524446c 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -51,17 +51,12 @@ export interface AuctionConfig { denom: string; } -export interface MiscConfig { - projectDomain: string; -} - export interface Config { server: ServerConfig; database: DatabaseConfig; gitHub: GitHubConfig; registryConfig: RegistryConfig; auction: AuctionConfig; - misc: MiscConfig; turnkey: { apiBaseUrl: string; apiPublicKey: string; diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index 8bf75d32..bae1769c 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -13,7 +13,7 @@ import assert from 'assert'; import { customAlphabet } from 'nanoid'; import { lowercase, numbers } from 'nanoid-dictionary'; -import { DatabaseConfig, MiscConfig } from './config'; +import { DatabaseConfig } from './config'; import { User } from './entity/User'; import { Organization } from './entity/Organization'; import { Project } from './entity/Project'; diff --git a/packages/backend/src/entity/Deployer.ts b/packages/backend/src/entity/Deployer.ts index 854ab1dd..4328a122 100644 --- a/packages/backend/src/entity/Deployer.ts +++ b/packages/backend/src/entity/Deployer.ts @@ -15,6 +15,9 @@ export class Deployer { @Column('varchar') baseDomain!: string; + @Column('varchar', { nullable: true }) + minimumPayment!: string | null; + @ManyToMany(() => Project, (project) => project.deployers) projects!: Project[]; } diff --git a/packages/backend/src/entity/Project.ts b/packages/backend/src/entity/Project.ts index 9edfdfd5..aa622dd7 100644 --- a/packages/backend/src/entity/Project.ts +++ b/packages/backend/src/entity/Project.ts @@ -66,6 +66,12 @@ export class Project { @Column('varchar', { nullable: true }) framework!: string | null; + @Column('varchar') + paymentAddress!: string; + + @Column('varchar') + txHash!: string; + @Column({ type: 'simple-array' }) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 629bd6e7..7e4ab14a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -17,7 +17,7 @@ const log = debug('snowball:server'); const OAUTH_CLIENT_TYPE = 'oauth-app'; export const main = async (): Promise => { - const { server, database, gitHub, registryConfig, misc } = await getConfig(); + const { server, database, gitHub, registryConfig } = await getConfig(); const app = new OAuthApp({ clientType: OAUTH_CLIENT_TYPE, diff --git a/packages/backend/src/registry.ts b/packages/backend/src/registry.ts index 59f3a178..9f3b314c 100644 --- a/packages/backend/src/registry.ts +++ b/packages/backend/src/registry.ts @@ -5,7 +5,8 @@ import { Octokit } from 'octokit'; import { inc as semverInc } from 'semver'; import { DeepPartial } from 'typeorm'; -import { Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk'; +import { Account, Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk'; +import { IndexedTx } from '@cosmjs/stargate'; import { RegistryConfig } from './config'; import { @@ -483,6 +484,36 @@ export class Registry { return this.registry.getAuctionsByIds([auctionId]); } + async sendTokensToAccount(receiverAddress: string, amount: string): Promise { + const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); + await registryTransactionWithRetry(() => + this.registry.sendCoins( + { + amount, + denom: 'alnt', + destinationAddress: receiverAddress + }, + this.registryConfig.privateKey, + fee + ) + ); + } + + async getAccount(): Promise { + const account = new Account(Buffer.from(this.registryConfig.privateKey, 'hex')); + await account.init(); + + return account; + } + + async getTxResponse(txHash: string): Promise { + const account = await this.getAccount(); + const laconicClient = await this.registry.getLaconicClient(account); + const txResponse: IndexedTx | null = await laconicClient.getTx(txHash); + + return txResponse; + } + getLrn(appName: string): string { assert(this.registryConfig.authority, "Authority doesn't exist"); return `lrn://${this.registryConfig.authority}/applications/${appName}`; diff --git a/packages/backend/src/resolvers.ts b/packages/backend/src/resolvers.ts index b4c2c01c..3fe9a9be 100644 --- a/packages/backend/src/resolvers.ts +++ b/packages/backend/src/resolvers.ts @@ -80,6 +80,21 @@ export const createResolvers = async (service: Service): Promise => { deployers: async (_: any, __: any, context: any) => { return service.getDeployers(); }, + + address: async (_: any, __: any, context: any) => { + return service.getAddress(); + }, + + verifyTx: async ( + _: any, + { + txHash, + amount, + senderAddress, + }: { txHash: string; amount: string; senderAddress: string }, + ) => { + return service.verifyTx(txHash, amount, senderAddress); + }, }, // TODO: Return error in GQL response @@ -221,7 +236,7 @@ export const createResolvers = async (service: Service): Promise => { organizationSlug: string; data: AddProjectFromTemplateInput; lrn: string; - auctionParams: AuctionParams, + auctionParams: AuctionParams; environmentVariables: EnvironmentVariables[]; }, context: any, diff --git a/packages/backend/src/schema.gql b/packages/backend/src/schema.gql index 5cead09e..3f7a615d 100644 --- a/packages/backend/src/schema.gql +++ b/packages/backend/src/schema.gql @@ -77,6 +77,8 @@ type Project { fundsReleased: Boolean template: String framework: String + paymentAddress: String! + txHash: String! webhooks: [String!] members: [ProjectMember!] environmentVariables: [EnvironmentVariable!] @@ -137,6 +139,7 @@ type Deployer { deployerLrn: String! deployerId: String! deployerApiUrl: String! + minimumPayment: String createdAt: String! updatedAt: String! } @@ -157,6 +160,8 @@ input AddProjectFromTemplateInput { owner: String! name: String! isPrivate: Boolean! + paymentAddress: String! + txHash: String! } input AddProjectInput { @@ -164,6 +169,8 @@ input AddProjectInput { repository: String! prodBranch: String! template: String + paymentAddress: String! + txHash: String! } input UpdateProjectInput { @@ -258,6 +265,8 @@ type Query { getAuctionData(auctionId: String!): Auction! domains(projectId: String!, filter: FilterDomainsInput): [Domain] deployers: [Deployer] + address: String! + verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean! } type Mutation { diff --git a/packages/backend/src/service.ts b/packages/backend/src/service.ts index 028b662b..e697b663 100644 --- a/packages/backend/src/service.ts +++ b/packages/backend/src/service.ts @@ -212,6 +212,9 @@ export class Service { if (!deployment.project.fundsReleased) { const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId); + // Return remaining amount to owner + await this.returnUserFundsByProjectId(deployment.projectId, true); + await this.db.updateProjectById(deployment.projectId, { fundsReleased, }); @@ -309,6 +312,9 @@ export class Service { if (!deployerRecords) { log(`No winning deployer for auction ${project!.auctionId}`); + + // Return all funds to the owner + await this.returnUserFundsByProjectId(project.id, false) } else { const deployers = await this.saveDeployersByDeployerRecords(deployerRecords); for (const deployer of deployers) { @@ -829,6 +835,8 @@ export class Service { repository: gitRepo.data.full_name, // TODO: Set selected template template: 'webapp', + paymentAddress: data.paymentAddress, + txHash: data.txHash }, lrn, auctionParams, environmentVariables); if (!project || !project.id) { @@ -1324,6 +1332,30 @@ export class Service { return false; } + async returnUserFundsByProjectId(projectId: string, winningDeployersPresent: boolean) { + const project = await this.db.getProjectById(projectId); + + if (!project || !project.auctionId) { + log(`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`); + + return false; + } + + const auction = await this.getAuctionData(project.auctionId); + + let amountToBeReturned; + if (winningDeployersPresent) { + amountToBeReturned = auction.winnerPrice * auction.numProviders; + } else { + amountToBeReturned = auction.maxPrice * auction.numProviders; + } + + await this.laconicRegistry.sendTokensToAccount( + project.paymentAddress, + amountToBeReturned.toString() + ); + } + async getDeployers(): Promise { const dbDeployers = await this.db.getDeployers(); @@ -1352,13 +1384,15 @@ export class Service { const deployerId = record.id; const deployerLrn = record.names[0]; const deployerApiUrl = record.attributes.apiUrl; + const minimumPayment = record.attributes.minimumPayment const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1); const deployerData = { deployerLrn, deployerId, deployerApiUrl, - baseDomain + baseDomain, + minimumPayment }; // TODO: Update deployers table in a separate job @@ -1369,4 +1403,32 @@ export class Service { return deployers; } + + async getAddress(): Promise { + const account = await this.laconicRegistry.getAccount(); + + return account.address; + } + + async verifyTx(txHash: string, amountSent: string, senderAddress: string): Promise { + const txResponse = await this.laconicRegistry.getTxResponse(txHash); + if (!txResponse) { + log('Transaction response not found'); + return false; + } + + const transfer = txResponse.events.find(e => e.type === 'transfer' && e.attributes.some(a => a.key === 'msg_index')); + if (!transfer) { + log('No transfer event found'); + return false; + } + + const sender = transfer.attributes.find(a => a.key === 'sender')?.value; + const recipient = transfer.attributes.find(a => a.key === 'recipient')?.value; + const amount = transfer.attributes.find(a => a.key === 'amount')?.value; + + const recipientAddress = await this.getAddress(); + + return amount === amountSent && sender === senderAddress && recipient === recipientAddress; + } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 4ea4123c..83c810ae 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -70,6 +70,8 @@ export interface AddProjectFromTemplateInput { owner: string; name: string; isPrivate: boolean; + paymentAddress: string; + txHash: string; } export interface AuctionParams { @@ -92,6 +94,7 @@ export interface DeployerRecord { expiryTime: string; attributes: { apiUrl: string; + minimumPayment: string | null; name: string; paymentAddress: string; publicKey: string; diff --git a/packages/deployer/deploy-frontend.sh b/packages/deployer/deploy-frontend.sh index 4af80bf1..b210d8bd 100755 --- a/packages/deployer/deploy-frontend.sh +++ b/packages/deployer/deploy-frontend.sh @@ -127,6 +127,7 @@ record: LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example LACONIC_HOSTED_CONFIG_wallet_connect_id: 63cad7ba97391f63652161f484670e15 + LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2 meta: note: Added by Snowball @ $CURRENT_DATE_TIME repository: "$REPO_URL" diff --git a/packages/deployer/deploy-frontend.staging.sh b/packages/deployer/deploy-frontend.staging.sh index 2672ddbd..09ebb820 100755 --- a/packages/deployer/deploy-frontend.staging.sh +++ b/packages/deployer/deploy-frontend.staging.sh @@ -41,6 +41,7 @@ record: LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2 + LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2 LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073 diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example index 24d6d6da..ac6ba474 100644 --- a/packages/frontend/.env.example +++ b/packages/frontend/.env.example @@ -15,3 +15,5 @@ VITE_BUGSNAG_API_KEY= VITE_PASSKEY_WALLET_RPID= VITE_TURNKEY_API_BASE_URL= VITE_TURNKEY_ORGANIZATION_ID= + +VITE_LACONICD_CHAIN_ID= diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d395b256..b0104445 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -30,10 +30,6 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", - "@snowballtools/auth": "^0.2.0", - "@snowballtools/auth-lit": "^0.2.0", - "@snowballtools/js-sdk": "^0.1.1", - "@snowballtools/link-lit-alchemy-light": "^0.2.0", "@snowballtools/material-tailwind-react-fork": "^2.1.10", "@snowballtools/smartwallet-alchemy-light": "^0.2.0", "@snowballtools/types": "^0.2.0", diff --git a/packages/frontend/src/components/StopWatch.tsx b/packages/frontend/src/components/StopWatch.tsx index fb512e24..34e7d97b 100644 --- a/packages/frontend/src/components/StopWatch.tsx +++ b/packages/frontend/src/components/StopWatch.tsx @@ -17,15 +17,13 @@ interface StopwatchProps extends Omit { } const Stopwatch = ({ offsetTimestamp, isPaused, ...props }: StopwatchProps) => { - const { totalSeconds, pause } = useStopwatch({ + const { totalSeconds, pause, start } = useStopwatch({ autoStart: true, offsetTimestamp: offsetTimestamp, }); useEffect(() => { - if (isPaused) { - pause(); - } + isPaused ? pause() : start(); }, [isPaused]); return ; diff --git a/packages/frontend/src/components/projects/create/Configure.tsx b/packages/frontend/src/components/projects/create/Configure.tsx index 058d18c7..711b448b 100644 --- a/packages/frontend/src/components/projects/create/Configure.tsx +++ b/packages/frontend/src/components/projects/create/Configure.tsx @@ -22,6 +22,8 @@ import { useToast } from 'components/shared/Toast'; import { useGQLClient } from '../../../context/GQLClientContext'; import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm'; import { EnvironmentVariablesFormValues } from 'types/types'; +import ConnectWallet from './ConnectWallet'; +import { useWalletConnectClient } from 'context/WalletConnectContext'; type ConfigureDeploymentFormValues = { option: string; @@ -33,9 +35,17 @@ type ConfigureDeploymentFormValues = { type ConfigureFormValues = ConfigureDeploymentFormValues & EnvironmentVariablesFormValues; +const DEFAULT_MAX_PRICE = '10000'; + const Configure = () => { + const { signClient, session, accounts } = useWalletConnectClient(); + const [isLoading, setIsLoading] = useState(false); const [deployers, setDeployers] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(); + const [selectedDeployer, setSelectedDeployer] = useState(); + const [isPaymentLoading, setIsPaymentLoading] = useState(false); + const [isPaymentDone, setIsPaymentDone] = useState(false); const [searchParams] = useSearchParams(); const templateId = searchParams.get('templateId'); @@ -55,7 +65,12 @@ const Configure = () => { const client = useGQLClient(); const methods = useForm({ - defaultValues: { option: 'Auction' }, + defaultValues: { + option: 'Auction', + maxPrice: DEFAULT_MAX_PRICE, + lrn: '', + numProviders: 1, + }, }); const selectedOption = methods.watch('option'); @@ -66,6 +81,8 @@ const Configure = () => { const createProject = async ( data: FieldValues, envVariables: AddEnvironmentVariableInput[], + senderAddress: string, + txHash: string, ): Promise => { setIsLoading(true); let projectId: string | null = null; @@ -90,6 +107,8 @@ const Configure = () => { owner, name, isPrivate, + paymentAddress: senderAddress, + txHash, }; const { addProjectFromTemplate } = await client.addProjectFromTemplate( @@ -109,6 +128,8 @@ const Configure = () => { prodBranch: defaultBranch!, repository: fullName!, template: 'webapp', + paymentAddress: senderAddress, + txHash, }, lrn, auctionParams, @@ -136,8 +157,77 @@ const Configure = () => { } }; + const verifyTx = async ( + senderAddress: string, + txHash: string, + amount: string, + ): Promise => { + const isValid = await client.verifyTx( + txHash, + `${amount.toString()}alnt`, + senderAddress, + ); + + return isValid; + }; + const handleFormSubmit = useCallback( async (createFormData: FieldValues) => { + if (!selectedAccount) { + return; + } + + const senderAddress = selectedAccount; + const deployerLrn = createFormData.lrn; + const deployer = deployers.find( + (deployer) => deployer.deployerLrn === deployerLrn, + ); + + let amount: string; + let txHash: string; + if (createFormData.option === 'LRN' && !deployer?.minimumPayment) { + toast({ + id: 'no-payment-required', + title: 'No payment required. Deploying app...', + variant: 'info', + onDismiss: dismiss, + }); + + txHash = ''; + } else { + if (createFormData.option === 'LRN') { + amount = deployer?.minimumPayment!; + } else { + amount = ( + createFormData.numProviders * createFormData.maxPrice + ).toString(); + } + + const amountToBePaid = amount.replace(/\D/g, '').toString(); + + const txHashResponse = await cosmosSendTokensHandler( + selectedAccount, + amountToBePaid, + ); + + if (!txHashResponse) { + console.error('Tx not successful'); + return; + } + + txHash = txHashResponse; + + const isTxHashValid = await verifyTx( + senderAddress, + txHash, + amount.toString(), + ); + if (isTxHashValid === false) { + console.error('Invalid Tx hash', txHash); + return; + } + } + const environmentVariables = createFormData.variables.map( (variable: any) => { return { @@ -153,6 +243,8 @@ const Configure = () => { const projectId = await createProject( createFormData, environmentVariables, + senderAddress.split(':')[2], + txHash, ); await client.getEnvironmentVariables(projectId); @@ -183,6 +275,83 @@ const Configure = () => { setDeployers(res.deployers); }, [client]); + const onAccountChange = useCallback((account: string) => { + setSelectedAccount(account); + }, []); + + 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 (!signClient || !session || !selectedAccount) { + return; + } + + const chainId = selectedAccount.split(':')[1]; + const senderAddress = selectedAccount.split(':')[2]; + const snowballAddress = await client.getAddress(); + + try { + setIsPaymentDone(false); + setIsPaymentLoading(true); + + toast({ + id: 'sending-payment-request', + title: 'Check your wallet and approve payment request', + variant: 'loading', + onDismiss: dismiss, + }); + + const result: { signature: string } = await signClient.request({ + topic: session.topic, + chainId: `cosmos:${chainId}`, + request: { + method: 'cosmos_sendTokens', + params: [ + { + from: senderAddress, + to: snowballAddress, + value: amount, + }, + ], + }, + }); + + if (!result) { + throw new Error('Error completing transaction'); + } + + toast({ + id: 'payment-successful', + title: 'Payment successful', + variant: 'success', + onDismiss: dismiss, + }); + + setIsPaymentDone(true); + + return result.signature; + } catch (error: any) { + console.error('Error sending tokens', error); + + toast({ + id: 'error-sending-tokens', + title: 'Error sending tokens', + variant: 'error', + onDismiss: dismiss, + }); + + setIsPaymentDone(false); + } finally { + setIsPaymentLoading(false); + } + }, + [session, signClient, toast], + ); + useEffect(() => { fetchDeployers(); }, []); @@ -249,7 +418,10 @@ const Configure = () => { @@ -291,7 +463,11 @@ const Configure = () => { control={methods.control} rules={{ required: true }} render={({ field: { value, onChange } }) => ( - + onChange(e)} + /> )} /> @@ -318,22 +494,56 @@ const Configure = () => { -
- -
+ {selectedOption === 'LRN' && + !selectedDeployer?.minimumPayment ? ( +
+ +
+ ) : ( + <> + + Connect to your wallet + + + {accounts && accounts?.length > 0 && ( +
+ +
+ )} + + )} diff --git a/packages/frontend/src/components/projects/create/ConnectWallet.tsx b/packages/frontend/src/components/projects/create/ConnectWallet.tsx new file mode 100644 index 00000000..5cdedcc3 --- /dev/null +++ b/packages/frontend/src/components/projects/create/ConnectWallet.tsx @@ -0,0 +1,46 @@ +import { Select, Option } from '@snowballtools/material-tailwind-react-fork'; + +import { Button } from '../../shared/Button'; +import { useWalletConnectClient } from 'context/WalletConnectContext'; + +const ConnectWallet = ({ + onAccountChange, +}: { + onAccountChange: (selectedAccount: string) => void; +}) => { + const { onConnect, accounts } = useWalletConnectClient(); + + const handleConnect = async () => { + await onConnect(); + }; + + return ( +
+ {!accounts ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); +}; + +export default ConnectWallet; diff --git a/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx b/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx index 5dbabfee..bbf31076 100644 --- a/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx +++ b/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx @@ -93,20 +93,20 @@ const DeploymentDetailsCard = ({ }; const fetchDeploymentLogs = async () => { + setDeploymentLogs('Loading logs...'); + handleOpenDialog(); const statusUrl = `${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`; const statusRes = await fetch(statusUrl, { cache: 'no-store' }).then( (res) => res.json(), ); if (!statusRes.logAvailable) { setDeploymentLogs(statusRes.lastState); - handleOpenDialog(); } else { const logsUrl = `${deployment.deployer.deployerApiUrl}/log/${deployment.applicationDeploymentRequestId}`; const logsRes = await fetch(logsUrl, { cache: 'no-store' }).then((res) => res.text(), ); setDeploymentLogs(logsRes); - handleOpenDialog(); } }; diff --git a/packages/frontend/src/context/WalletConnectContext.tsx b/packages/frontend/src/context/WalletConnectContext.tsx new file mode 100644 index 00000000..6e8a6dd0 --- /dev/null +++ b/packages/frontend/src/context/WalletConnectContext.tsx @@ -0,0 +1,210 @@ +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 { StargateClient } from '@cosmjs/stargate'; + +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; + onDisconnect: () => Promise; + onSessionDelete: () => void; + accounts: { address: string }[] | undefined; +} + +const ClientContext = createContext({} as ClientInterface); + +export const useWalletConnectClient = () => { + return useContext(ClientContext); +}; + +export const WalletConnectClientProvider = ({ + children, +}: { + children: JSX.Element; +}) => { + const [signClient, setSignClient] = useState(); + const [session, setSession] = useState(); + const [loadingSession, setLoadingSession] = useState(true); + const [accounts, setAccounts] = useState<{ address: string }[]>(); + + const isSignClientInitializing = useRef(false); + + const createCosmosClient = useCallback(async (endpoint: string) => { + return await StargateClient.connect(endpoint); + }, []); + + 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(undefined); + 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; + } + const cosmosAddresses = session.namespaces['cosmos'].accounts; + + const cosmosAccounts = cosmosAddresses.map((address) => ({ + address, + })); + + const allAccounts = cosmosAccounts; + + setAccounts(allAccounts); + }; + + populateAccounts(); + }, [session, createCosmosClient]); + + useEffect(() => { + if (!signClient) { + return; + } + + signClient.on('session_delete', onSessionDelete); + + return () => { + signClient.off('session_delete', onSessionDelete); + }; + }); + + return ( + + {children} + + ); +}; diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx index 66812111..787cef61 100644 --- a/packages/frontend/src/index.tsx +++ b/packages/frontend/src/index.tsx @@ -16,6 +16,7 @@ import { Toaster } from 'components/shared/Toast'; import { LogErrorBoundary } from 'utils/log-error'; import { BASE_URL } from 'utils/constants'; import Web3ModalProvider from './context/Web3Provider'; +import { WalletConnectClientProvider } from 'context/WalletConnectContext'; console.log(`v-0.0.9`); @@ -31,14 +32,16 @@ const gqlClient = new GQLClient({ gqlEndpoint }); root.render( - - - - - - - - + + + + + + + + + + , ); diff --git a/packages/frontend/src/pages/auth/CreatePasskey.tsx b/packages/frontend/src/pages/auth/CreatePasskey.tsx deleted file mode 100644 index b976f973..00000000 --- a/packages/frontend/src/pages/auth/CreatePasskey.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Button } from 'components/shared/Button'; -import { LoaderIcon } from 'components/shared/CustomIcon'; -import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon'; -import { InlineNotification } from 'components/shared/InlineNotification'; -import { Input } from 'components/shared/Input'; -import { WavyBorder } from 'components/shared/WavyBorder'; -import { useState } from 'react'; -import { IconRight } from 'react-day-picker'; -import { useSnowball } from 'utils/use-snowball'; - -type Props = { - onDone: () => void; -}; - -export const CreatePasskey = ({}: Props) => { - const snowball = useSnowball(); - const [name, setName] = useState(''); - - const auth = snowball.auth.passkey; - const loading = !!auth.state.loading; - - async function createPasskey() { - await auth.register(name); - } - - return ( -
-
-
- -
-
-
- Create a passkey -
-
- Passkeys allow you to sign in securely without using passwords. -
-
-
- -
-
-
-
-
- Give it a name -
-
- { - setName(e.target.value); - }} - /> -
- - {auth.state.error ? ( - - ) : ( - - )} -
- -
-
- ); -}; diff --git a/packages/frontend/src/pages/components/modals.tsx b/packages/frontend/src/pages/components/modals.tsx index 91d5f29b..cf734d31 100644 --- a/packages/frontend/src/pages/components/modals.tsx +++ b/packages/frontend/src/pages/components/modals.tsx @@ -40,6 +40,7 @@ const deployment: Deployment = { deployerApiUrl: 'https://webapp-deployer-api.example.com', deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu', deployerLrn: 'lrn://example/deployers/webapp-deployer-api.example.com', + minimumPayment: '1000alnt', }, status: DeploymentStatus.Ready, createdBy: { diff --git a/packages/frontend/src/pages/org-slug/projects/create/template/index.tsx b/packages/frontend/src/pages/org-slug/projects/create/template/index.tsx index 9879c56d..7e855240 100644 --- a/packages/frontend/src/pages/org-slug/projects/create/template/index.tsx +++ b/packages/frontend/src/pages/org-slug/projects/create/template/index.tsx @@ -14,7 +14,6 @@ import { ArrowRightCircleFilledIcon, LoadingIcon, } from 'components/shared/CustomIcon'; -import { Checkbox } from 'components/shared/Checkbox'; import { Button } from 'components/shared/Button'; import { useToast } from 'components/shared/Toast'; @@ -169,15 +168,6 @@ const CreateRepo = () => { )} /> -
- ( - - )} - /> -