Compare commits

..

14 Commits

Author SHA1 Message Date
c8183ba355 chore: rename to laconic 2024-10-30 11:45:25 -04:00
1cb7c5a1f4 style: projects and settings 2024-10-30 11:45:20 -04:00
f50f4b9a4d style: rename color 2024-10-30 11:40:15 -04:00
017d773d81 style: more colors 2024-10-30 11:40:15 -04:00
284721234b style: first pass at laconic colors 2024-10-30 11:40:10 -04:00
ea9a56eb65 Display DNS deployment URLs in overview section (#21)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Disable `Deploy` button in configure step if account and deployer not selected
- Update organization slug
- Only display project if current user is project owner

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#21
2024-10-30 13:11:04 +00:00
05bd766133 Display project URLs in Overview tab (#20)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Fix project create not working after failed tx
- Poll for project details for auction details
- Update wallet connect metadata

![image](/attachments/cd0217c9-8a2f-4bc5-ad4c-2654fa92f958)

Co-authored-by: Neeraj <neeraj.rtly@gmail.com>
Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#20
2024-10-29 14:10:01 +00:00
0f18bc978e Pass payment tx hash in deployment request (#19)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#19
2024-10-29 09:12:39 +00:00
519e318190 Check if repo with same name already exists when creating project (#18)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

![image](/attachments/6e0efb39-db83-4140-b840-3eca84c3e0f2)

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#18
2024-10-28 11:23:22 +00:00
63969ae25a 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 <ishavenikar7@gmail.com>
Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#17
2024-10-28 09:46:18 +00:00
b449c299dc Comment out bugsnag code (#16)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Reviewed-on: cerc-io/snowballtools-base#16
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun <nabarun@deepstacksoft.com>
2024-10-25 12:40:34 +00:00
2a35ec1cd5 Check deployment status while creating project with single deployer (#15)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)
- Use deployer API to get status of the deployments

Co-authored-by: Shreerang Kale <shreerangkale@gmail.com>
Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#15
Co-authored-by: Nabarun Gogoi <nabarun@deepstacksoft.com>
Co-committed-by: Nabarun Gogoi <nabarun@deepstacksoft.com>
2024-10-25 10:47:04 +00:00
be90fc76c1 Update script to pay webapp deployer before deployment request (#14)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

Co-authored-by: Adw8 <adwaitgharpure@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#14
2024-10-25 10:01:22 +00:00
3fa60f3cdf Handle account sequence mismatch error (#13)
Part of [Service provider auctions for web deployments](https://www.notion.so/Service-provider-auctions-for-web-deployments-104a6b22d47280dbad51d28aa3a91d75)

- Handle failed txs due to `account sequence mismatch` error by creating a wrapper for all tx methods and retry the tx if `account sequence mismatch` error occurs

Co-authored-by: IshaVenikar <ishavenikar7@gmail.com>
Reviewed-on: cerc-io/snowballtools-base#13
2024-10-24 11:38:17 +00:00
56 changed files with 1392 additions and 1957 deletions

View File

@ -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_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_pwa_templaterepo'
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_image_upload_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_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_LIT_RELAY_API_KEY = 'LACONIC_HOSTED_CONFIG_lit_relay_api_key'
VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key' VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key'
VITE_PASSKEY_WALLET_RPID = 'LACONIC_HOSTED_CONFIG_passkey_wallet_rpid' VITE_PASSKEY_WALLET_RPID = 'LACONIC_HOSTED_CONFIG_passkey_wallet_rpid'

View File

@ -20,16 +20,6 @@
clientId = "" clientId = ""
clientSecret = "" clientSecret = ""
[google]
clientId = ""
clientSecret = ""
[turnkey]
apiBaseUrl = "https://api.turnkey.com"
apiPrivateKey = ""
apiPublicKey = ""
defaultOrganizationId = ""
[registryConfig] [registryConfig]
fetchDeploymentRecordDelay = 5000 fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000 checkAuctionStatusDelay = 5000
@ -51,6 +41,3 @@
revealFee = "100000" revealFee = "100000"
revealsDuration = "120s" revealsDuration = "120s"
denom = "alnt" denom = "alnt"
[misc]
projectDomain = "apps.snowballtools.com"

View File

@ -51,17 +51,12 @@ export interface AuctionConfig {
denom: string; denom: string;
} }
export interface MiscConfig {
projectDomain: string;
}
export interface Config { export interface Config {
server: ServerConfig; server: ServerConfig;
database: DatabaseConfig; database: DatabaseConfig;
gitHub: GitHubConfig; gitHub: GitHubConfig;
registryConfig: RegistryConfig; registryConfig: RegistryConfig;
auction: AuctionConfig; auction: AuctionConfig;
misc: MiscConfig;
turnkey: { turnkey: {
apiBaseUrl: string; apiBaseUrl: string;
apiPublicKey: string; apiPublicKey: string;

View File

@ -13,7 +13,7 @@ import assert from 'assert';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary'; import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig, MiscConfig } from './config'; import { DatabaseConfig } from './config';
import { User } from './entity/User'; import { User } from './entity/User';
import { Organization } from './entity/Organization'; import { Organization } from './entity/Organization';
import { Project } from './entity/Project'; import { Project } from './entity/Project';
@ -140,6 +140,7 @@ export class Database {
) )
.leftJoinAndSelect('deployments.createdBy', 'user') .leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.domain', 'domain') .leftJoinAndSelect('deployments.domain', 'domain')
.leftJoinAndSelect('deployments.deployer', 'deployer')
.leftJoinAndSelect('project.owner', 'owner') .leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.deployers', 'deployers') .leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization') .leftJoinAndSelect('project.organization', 'organization')

View File

@ -15,6 +15,12 @@ export class Deployer {
@Column('varchar') @Column('varchar')
baseDomain!: string; baseDomain!: string;
@Column('varchar', { nullable: true })
minimumPayment!: string | null;
@Column('varchar', { nullable: true })
paymentAddress!: string | null;
@ManyToMany(() => Project, (project) => project.deployers) @ManyToMany(() => Project, (project) => project.deployers)
projects!: Project[]; projects!: Project[];
} }

View File

@ -38,19 +38,17 @@ export interface ApplicationDeploymentRequest {
auction?: string; auction?: string;
config: string; config: string;
meta: string; meta: string;
payment?: string;
} }
export interface ApplicationDeploymentRemovalRequest { export interface ApplicationDeploymentRemovalRequest {
type: string; type: string;
version: string; version: string;
deployment: string; deployment: string;
auction?: string;
payment?: string;
} }
export interface ApplicationDeploymentRemovalRequest {
type: string;
version: string;
deployment: string;
}
export interface ApplicationRecord { export interface ApplicationRecord {
type: string; type: string;

View File

@ -52,6 +52,10 @@ export class Project {
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
auctionId!: string | null; auctionId!: string | null;
// Tx hash for sending coins from snowball to deployer
@Column('varchar', { nullable: true })
txHash!: string | null;
@ManyToMany(() => Deployer, (deployer) => (deployer.projects)) @ManyToMany(() => Deployer, (deployer) => (deployer.projects))
@JoinTable() @JoinTable()
deployers!: Deployer[] deployers!: Deployer[]
@ -66,6 +70,10 @@ export class Project {
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
framework!: string | null; framework!: string | null;
// Address of the user who created the project i.e. requested deployments
@Column('varchar')
paymentAddress!: string;
@Column({ @Column({
type: 'simple-array' type: 'simple-array'
}) })

View File

@ -17,7 +17,7 @@ const log = debug('snowball:server');
const OAUTH_CLIENT_TYPE = 'oauth-app'; const OAUTH_CLIENT_TYPE = 'oauth-app';
export const main = async (): Promise<void> => { export const main = async (): Promise<void> => {
const { server, database, gitHub, registryConfig, misc } = await getConfig(); const { server, database, gitHub, registryConfig } = await getConfig();
const app = new OAuthApp({ const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE, clientType: OAUTH_CLIENT_TYPE,

View File

@ -5,7 +5,8 @@ import { Octokit } from 'octokit';
import { inc as semverInc } from 'semver'; import { inc as semverInc } from 'semver';
import { DeepPartial } from 'typeorm'; import { DeepPartial } from 'typeorm';
import { Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk'; import { Account, DEFAULT_GAS_ESTIMATION_MULTIPLIER, Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk';
import { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate';
import { RegistryConfig } from './config'; import { RegistryConfig } from './config';
import { import {
@ -15,7 +16,7 @@ import {
ApplicationDeploymentRemovalRequest ApplicationDeploymentRemovalRequest
} from './entity/Deployment'; } from './entity/Deployment';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, AuctionParams, DeployerRecord } from './types'; import { AppDeploymentRecord, AppDeploymentRemovalRecord, AuctionParams, DeployerRecord } from './types';
import { getConfig, getRepoDetails, sleep } from './utils'; import { getConfig, getRepoDetails, registryTransactionWithRetry, sleep } from './utils';
const log = debug('snowball:registry'); const log = debug('snowball:registry');
@ -108,14 +109,16 @@ export class Registry {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const result = await this.registry.setRecord( const result = await registryTransactionWithRetry(() =>
{ this.registry.setRecord(
privateKey: this.registryConfig.privateKey, {
record: applicationRecord, privateKey: this.registryConfig.privateKey,
bondId: this.registryConfig.bondId record: applicationRecord,
}, bondId: this.registryConfig.bondId
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
log(`Published application record ${result.id}`); log(`Published application record ${result.id}`);
@ -126,33 +129,39 @@ export class Registry {
log(`Setting name: ${lrn} for record ID: ${result.id}`); log(`Setting name: ${lrn} for record ID: ${result.id}`);
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
await this.registry.setName( await registryTransactionWithRetry(() =>
{ this.registry.setName(
cid: result.id, {
lrn cid: result.id,
}, lrn
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
await this.registry.setName( await registryTransactionWithRetry(() =>
{ this.registry.setName(
cid: result.id, {
lrn: `${lrn}@${applicationRecord.app_version}` cid: result.id,
}, lrn: `${lrn}@${applicationRecord.app_version}`
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
await this.registry.setName( await registryTransactionWithRetry(() =>
{ this.registry.setName(
cid: result.id, {
lrn: `${lrn}@${applicationRecord.repository_ref}` cid: result.id,
}, lrn: `${lrn}@${applicationRecord.repository_ref}`
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
return { return {
@ -183,19 +192,21 @@ export class Registry {
const auctionConfig = config.auction; const auctionConfig = config.auction;
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const auctionResult = await this.registry.createProviderAuction( const auctionResult = await registryTransactionWithRetry(() =>
{ this.registry.createProviderAuction(
commitFee: auctionConfig.commitFee, {
commitsDuration: auctionConfig.commitsDuration, commitFee: auctionConfig.commitFee,
revealFee: auctionConfig.revealFee, commitsDuration: auctionConfig.commitsDuration,
revealsDuration: auctionConfig.revealsDuration, revealFee: auctionConfig.revealFee,
denom: auctionConfig.denom, revealsDuration: auctionConfig.revealsDuration,
maxPrice: auctionParams.maxPrice, denom: auctionConfig.denom,
numProviders: auctionParams.numProviders, maxPrice: auctionParams.maxPrice,
}, numProviders: auctionParams.numProviders,
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
) fee
)
);
if (!auctionResult.auction) { if (!auctionResult.auction) {
throw new Error('Error creating auction'); throw new Error('Error creating auction');
@ -208,14 +219,16 @@ export class Registry {
type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE, type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE,
}; };
const result = await this.registry.setRecord( const result = await registryTransactionWithRetry(() =>
{ this.registry.setRecord(
privateKey: this.registryConfig.privateKey, {
record: applicationDeploymentAuction, privateKey: this.registryConfig.privateKey,
bondId: this.registryConfig.bondId record: applicationDeploymentAuction,
}, bondId: this.registryConfig.bondId
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
log(`Application deployment auction created: ${auctionResult.auction.id}`); log(`Application deployment auction created: ${auctionResult.auction.id}`);
@ -231,10 +244,11 @@ export class Registry {
deployment: Deployment, deployment: Deployment,
appName: string, appName: string,
repository: string, repository: string,
auctionId?: string, auctionId?: string | null,
lrn: string, lrn: string,
environmentVariables: { [key: string]: string }, environmentVariables: { [key: string]: string },
dns: string, dns: string,
payment?: string | null
}): Promise<{ }): Promise<{
applicationDeploymentRequestId: string; applicationDeploymentRequestId: string;
applicationDeploymentRequestData: ApplicationDeploymentRequest; applicationDeploymentRequestData: ApplicationDeploymentRequest;
@ -268,20 +282,23 @@ export class Registry {
}), }),
deployer: data.lrn, deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }), ...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment }),
}; };
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const result = await this.registry.setRecord( const result = await registryTransactionWithRetry(() =>
{ this.registry.setRecord(
privateKey: this.registryConfig.privateKey, {
record: applicationDeploymentRequest, privateKey: this.registryConfig.privateKey,
bondId: this.registryConfig.bondId record: applicationDeploymentRequest,
}, bondId: this.registryConfig.bondId
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
log(`Application deployment request record published: ${result.id}`); log(`Application deployment request record published: ${result.id}`);
@ -307,7 +324,11 @@ export class Registry {
paymentAddress: auctionWinner, paymentAddress: auctionWinner,
}); });
for (const record of records) { const newRecords = records.filter(record => {
return record.names !== null && record.names.length > 0;
});
for (const record of newRecords) {
if (record.id) { if (record.id) {
deployerRecords.push(record); deployerRecords.push(record);
break; break;
@ -322,12 +343,14 @@ export class Registry {
auctionId: string auctionId: string
): Promise<any> { ): Promise<any> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const auction = await this.registry.releaseFunds( const auction = await registryTransactionWithRetry(() =>
{ this.registry.releaseFunds(
auctionId {
}, auctionId
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
return auction; return auction;
@ -411,6 +434,8 @@ export class Registry {
async createApplicationDeploymentRemovalRequest(data: { async createApplicationDeploymentRemovalRequest(data: {
deploymentId: string; deploymentId: string;
deployerLrn: string; deployerLrn: string;
auctionId?: string | null;
payment?: string | null;
}): Promise<{ }): Promise<{
applicationDeploymentRemovalRequestId: string; applicationDeploymentRemovalRequestId: string;
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest; applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest;
@ -419,19 +444,23 @@ export class Registry {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE, type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0', version: '1.0.0',
deployment: data.deploymentId, deployment: data.deploymentId,
deployer: data.deployerLrn deployer: data.deployerLrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment }),
}; };
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const result = await this.registry.setRecord( const result = await registryTransactionWithRetry(() =>
{ this.registry.setRecord(
privateKey: this.registryConfig.privateKey, {
record: applicationDeploymentRemovalRequest, privateKey: this.registryConfig.privateKey,
bondId: this.registryConfig.bondId record: applicationDeploymentRemovalRequest,
}, bondId: this.registryConfig.bondId
this.registryConfig.privateKey, },
fee this.registryConfig.privateKey,
fee
)
); );
log(`Application deployment removal request record published: ${result.id}`); log(`Application deployment removal request record published: ${result.id}`);
@ -465,6 +494,40 @@ export class Registry {
return this.registry.getAuctionsByIds([auctionId]); return this.registry.getAuctionsByIds([auctionId]);
} }
async sendTokensToAccount(receiverAddress: string, amount: string): Promise<DeliverTxResponse> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
const account = await this.getAccount();
const laconicClient = await this.registry.getLaconicClient(account);
const txResponse: DeliverTxResponse =
await registryTransactionWithRetry(() =>
laconicClient.sendTokens(account.address, receiverAddress,
[
{
denom: 'alnt',
amount
}
],
fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER)
);
return txResponse;
}
async getAccount(): Promise<Account> {
const account = new Account(Buffer.from(this.registryConfig.privateKey, 'hex'));
await account.init();
return account;
}
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
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 { getLrn(appName: string): string {
assert(this.registryConfig.authority, "Authority doesn't exist"); assert(this.registryConfig.authority, "Authority doesn't exist");
return `lrn://${this.registryConfig.authority}/applications/${appName}`; return `lrn://${this.registryConfig.authority}/applications/${appName}`;

View File

@ -22,8 +22,8 @@ export const createResolvers = async (service: Service): Promise<any> => {
return service.getOrganizationsByUserId(context.user); return service.getOrganizationsByUserId(context.user);
}, },
project: async (_: any, { projectId }: { projectId: string }) => { project: async (_: any, { projectId }: { projectId: string }, context: any) => {
return service.getProjectById(projectId); return service.getProjectById(context.user, projectId);
}, },
projectsInOrganization: async ( projectsInOrganization: async (
@ -80,6 +80,21 @@ export const createResolvers = async (service: Service): Promise<any> => {
deployers: async (_: any, __: any, context: any) => { deployers: async (_: any, __: any, context: any) => {
return service.getDeployers(); 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 // TODO: Return error in GQL response
@ -221,7 +236,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
organizationSlug: string; organizationSlug: string;
data: AddProjectFromTemplateInput; data: AddProjectFromTemplateInput;
lrn: string; lrn: string;
auctionParams: AuctionParams, auctionParams: AuctionParams;
environmentVariables: EnvironmentVariables[]; environmentVariables: EnvironmentVariables[];
}, },
context: any, context: any,

View File

@ -77,6 +77,8 @@ type Project {
fundsReleased: Boolean fundsReleased: Boolean
template: String template: String
framework: String framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!] webhooks: [String!]
members: [ProjectMember!] members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!] environmentVariables: [EnvironmentVariable!]
@ -137,8 +139,11 @@ type Deployer {
deployerLrn: String! deployerLrn: String!
deployerId: String! deployerId: String!
deployerApiUrl: String! deployerApiUrl: String!
minimumPayment: String
paymentAddress: String
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
baseDomain: String
} }
type AuthResult { type AuthResult {
@ -157,6 +162,8 @@ input AddProjectFromTemplateInput {
owner: String! owner: String!
name: String! name: String!
isPrivate: Boolean! isPrivate: Boolean!
paymentAddress: String!
txHash: String!
} }
input AddProjectInput { input AddProjectInput {
@ -164,6 +171,8 @@ input AddProjectInput {
repository: String! repository: String!
prodBranch: String! prodBranch: String!
template: String template: String
paymentAddress: String!
txHash: String!
} }
input UpdateProjectInput { input UpdateProjectInput {
@ -258,6 +267,8 @@ type Query {
getAuctionData(auctionId: String!): Auction! getAuctionData(auctionId: String!): Auction!
domains(projectId: String!, filter: FilterDomainsInput): [Domain] domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer] deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
} }
type Mutation { type Mutation {

View File

@ -212,6 +212,9 @@ export class Service {
if (!deployment.project.fundsReleased) { if (!deployment.project.fundsReleased) {
const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId); const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId);
// Return remaining amount to owner
await this.returnUserFundsByProjectId(deployment.projectId, true);
await this.db.updateProjectById(deployment.projectId, { await this.db.updateProjectById(deployment.projectId, {
fundsReleased, fundsReleased,
}); });
@ -309,6 +312,9 @@ export class Service {
if (!deployerRecords) { if (!deployerRecords) {
log(`No winning deployer for auction ${project!.auctionId}`); log(`No winning deployer for auction ${project!.auctionId}`);
// Return all funds to the owner
await this.returnUserFundsByProjectId(project.id, false)
} else { } else {
const deployers = await this.saveDeployersByDeployerRecords(deployerRecords); const deployers = await this.saveDeployersByDeployerRecords(deployerRecords);
for (const deployer of deployers) { for (const deployer of deployers) {
@ -401,8 +407,13 @@ export class Service {
return dbOrganizations; return dbOrganizations;
} }
async getProjectById(projectId: string): Promise<Project | null> { async getProjectById(user: User, projectId: string): Promise<Project | null> {
const dbProject = await this.db.getProjectById(projectId); const dbProject = await this.db.getProjectById(projectId);
if (dbProject && dbProject.owner.id !== user.id) {
return null;
}
return dbProject; return dbProject;
} }
@ -642,7 +653,9 @@ export class Service {
repository: repoUrl, repository: repoUrl,
environmentVariables: environmentVariablesObj, environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}`, dns: `${newDeployment.project.name}`,
lrn: deployer!.deployerLrn! lrn: deployer!.deployerLrn!,
payment: data.project.txHash,
auctionId: data.project.auctionId
}); });
} }
@ -654,6 +667,8 @@ export class Service {
lrn: deployer!.deployerLrn!, lrn: deployer!.deployerLrn!,
environmentVariables: environmentVariablesObj, environmentVariables: environmentVariablesObj,
dns: `${newDeployment.project.name}-${newDeployment.id}`, dns: `${newDeployment.project.name}-${newDeployment.id}`,
payment: data.project.txHash,
auctionId: data.project.auctionId
}); });
await this.db.updateDeploymentById(newDeployment.id, { await this.db.updateDeploymentById(newDeployment.id, {
@ -829,6 +844,8 @@ export class Service {
repository: gitRepo.data.full_name, repository: gitRepo.data.full_name,
// TODO: Set selected template // TODO: Set selected template
template: 'webapp', template: 'webapp',
paymentAddress: data.paymentAddress,
txHash: data.txHash
}, lrn, auctionParams, environmentVariables); }, lrn, auctionParams, environmentVariables);
if (!project || !project.id) { if (!project || !project.id) {
@ -878,21 +895,53 @@ export class Service {
per_page: 1, per_page: 1,
}); });
// Create deployment with prod branch and latest commit
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
};
if (auctionParams) { if (auctionParams) {
// Create deployment with prod branch and latest commit
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
};
const { applicationDeploymentAuctionId } = await this.laconicRegistry.createApplicationDeploymentAuction(repo, octokit, auctionParams!, deploymentData); const { applicationDeploymentAuctionId } = await this.laconicRegistry.createApplicationDeploymentAuction(repo, octokit, auctionParams!, deploymentData);
await this.updateProject(project.id, { auctionId: applicationDeploymentAuctionId }); await this.updateProject(project.id, { auctionId: applicationDeploymentAuctionId });
} else { } else {
const newDeployment = await this.createDeployment(user.id, octokit, deploymentData, lrn); const deployer = await this.db.getDeployerByLRN(lrn!);
if (!deployer) {
log('Invalid deployer LRN');
return;
}
if (deployer.minimumPayment && project.txHash) {
const amountToBePaid = deployer?.minimumPayment.replace(/\D/g, '').toString();
const txResponse = await this.laconicRegistry.sendTokensToAccount(
deployer?.paymentAddress!,
amountToBePaid
);
const txHash = txResponse.transactionHash;
if (txHash) {
await this.updateProject(project.id, { txHash });
project.txHash = txHash;
log('Funds transferrend to deployer');
}
}
const deploymentData = {
project,
branch: project.prodBranch,
environment: Environment.Production,
domain: null,
commitHash: latestCommit.sha,
commitMessage: latestCommit.commit.message,
deployer
};
const newDeployment = await this.createDeployment(user.id, octokit, deploymentData);
// Update project with deployer // Update project with deployer
await this.updateProjectWithDeployer(newDeployment.projectId, newDeployment.deployer); await this.updateProjectWithDeployer(newDeployment.projectId, newDeployment.deployer);
} }
@ -1133,14 +1182,18 @@ export class Service {
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: latestRecord.id, deploymentId: latestRecord.id,
deployerLrn: deployment.deployer.deployerLrn deployerLrn: deployment.deployer.deployerLrn,
auctionId: deployment.project.auctionId,
payment: deployment.project.txHash
}); });
} }
const result = const result =
await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ await this.laconicRegistry.createApplicationDeploymentRemovalRequest({
deploymentId: deployment.applicationDeploymentRecordId, deploymentId: deployment.applicationDeploymentRecordId,
deployerLrn: deployment.deployer.deployerLrn deployerLrn: deployment.deployer.deployerLrn,
auctionId: deployment.project.auctionId,
payment: deployment.project.txHash
}); });
await this.db.updateDeploymentById(deployment.id, { await this.db.updateDeploymentById(deployment.id, {
@ -1324,6 +1377,33 @@ export class Service {
return false; 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);
const totalAuctionPrice = Number(auction.maxPrice.quantity) * auction.numProviders;
let amountToBeReturned;
if (winningDeployersPresent) {
amountToBeReturned = totalAuctionPrice - auction.winnerAddresses.length * Number(auction.winnerPrice.quantity);
} else {
amountToBeReturned = totalAuctionPrice;
}
if (amountToBeReturned !== 0) {
await this.laconicRegistry.sendTokensToAccount(
project.paymentAddress,
amountToBeReturned.toString()
);
}
}
async getDeployers(): Promise<Deployer[]> { async getDeployers(): Promise<Deployer[]> {
const dbDeployers = await this.db.getDeployers(); const dbDeployers = await this.db.getDeployers();
@ -1348,17 +1428,21 @@ export class Service {
const deployers: Deployer[] = []; const deployers: Deployer[] = [];
for (const record of deployerRecords) { for (const record of deployerRecords) {
if (record.names.length > 0) { if (record.names && record.names.length > 0) {
const deployerId = record.id; const deployerId = record.id;
const deployerLrn = record.names[0]; const deployerLrn = record.names[0];
const deployerApiUrl = record.attributes.apiUrl; const deployerApiUrl = record.attributes.apiUrl;
const minimumPayment = record.attributes.minimumPayment;
const paymentAddress = record.attributes.paymentAddress;
const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1); const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1);
const deployerData = { const deployerData = {
deployerLrn, deployerLrn,
deployerId, deployerId,
deployerApiUrl, deployerApiUrl,
baseDomain baseDomain,
minimumPayment,
paymentAddress
}; };
// TODO: Update deployers table in a separate job // TODO: Update deployers table in a separate job
@ -1369,4 +1453,32 @@ export class Service {
return deployers; return deployers;
} }
async getAddress(): Promise<any> {
const account = await this.laconicRegistry.getAccount();
return account.address;
}
async verifyTx(txHash: string, amountSent: string, senderAddress: string): Promise<boolean> {
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;
}
} }

View File

@ -70,6 +70,8 @@ export interface AddProjectFromTemplateInput {
owner: string; owner: string;
name: string; name: string;
isPrivate: boolean; isPrivate: boolean;
paymentAddress: string;
txHash: string;
} }
export interface AuctionParams { export interface AuctionParams {
@ -92,6 +94,7 @@ export interface DeployerRecord {
expiryTime: string; expiryTime: string;
attributes: { attributes: {
apiUrl: string; apiUrl: string;
minimumPayment: string | null;
name: string; name: string;
paymentAddress: string; paymentAddress: string;
publicKey: string; publicKey: string;

View File

@ -120,3 +120,24 @@ export const getRepoDetails = async (
repoUrl repoUrl
}; };
} }
// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs
export const registryTransactionWithRetry = async (
txMethod: () => Promise<any>
): Promise<any> => {
try {
return await txMethod();
} catch (error: any) {
if (!error.message.includes('account sequence mismatch')) {
throw error;
}
console.error(`Transaction failed due to account sequence mismatch. Retrying...`);
try {
return await txMethod();
} catch (retryError: any) {
throw new Error(`Transaction failed again after retry: ${retryError.message}`);
}
}
}

View File

@ -1,8 +1,8 @@
[ [
{ {
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26", "id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Snowball Tools", "name": "Deploy Tools",
"slug": "snowball-tools-1" "slug": "deploy-tools"
}, },
{ {
"id": "7eb9b3eb-eb74-4b53-b59a-69884c82a7fb", "id": "7eb9b3eb-eb74-4b53-b59a-69884c82a7fb",

View File

@ -1,2 +1,3 @@
REGISTRY_BOND_ID= REGISTRY_BOND_ID=
DEPLOYER_LRN= DEPLOYER_LRN=
AUTHORITY=

View File

@ -1,10 +1,8 @@
services: services:
registry: registry:
rpcEndpoint: http://laconicd.laconic.com:26657 rpcEndpoint: https://laconicd-sapo.laconic.com
gqlEndpoint: http://laconicd.laconic.com:9473/api gqlEndpoint: https://laconicd-sapo.laconic.com/api
userKey: 08c0d30ed23706330468e6936316a3bc3e69e451e394f05027ad56119bb485b9 userKey:
bondId: 820587f916d9a6a056f1e6a5a250151d9fa0c1e771347a6b8bb3d6f2090fd11b bondId:
chainId: laconic_9000-2 chainId: laconic_9000-2
gas:
fees:
gasPrice: 1alnt gasPrice: 1alnt

View File

@ -3,6 +3,7 @@
source .env source .env
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID" echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN" echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Using AUTHORITY: $AUTHORITY"
# Repository URL # Repository URL
REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base" REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base"
@ -21,40 +22,13 @@ CONFIG_FILE=config.yml
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts # Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version # Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}') NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "deploy-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found # Set application-record version if no previous records were found
NEW_APPLICATION_VERSION=0.0.1 NEW_APPLICATION_VERSION=0.0.1
fi fi
# Generate application-deployment-request.yml
cat >./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: snowballtools-base-frontend@$PACKAGE_VERSION
application: lrn://snowballtools/applications/snowballtools-base-frontend@$PACKAGE_VERSION
deployer: $DEPLOYER_LRN
dns: dashboard
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://snowball-backend.pwa.laconic.com
LACONIC_HOSTED_CONFIG_github_clientid: b7c63b235ca1dd5639ab
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_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball
LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073
LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: dashboard.pwa.laconic.com
LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com
LACONIC_HOSTED_CONFIG_turnkey_organization_id: 5049ae99-5bca-40b3-8317-504384d4e591
meta:
note: Added by Snowball @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
repository_ref: $LATEST_HASH
EOF
# Generate application-record.yml with incremented version # Generate application-record.yml with incremented version
cat >./records/application-record.yml <<EOF cat >./records/application-record.yml <<EOF
record: record:
@ -63,11 +37,11 @@ record:
repository_ref: $LATEST_HASH repository_ref: $LATEST_HASH
repository: ["$REPO_URL"] repository: ["$REPO_URL"]
app_type: webapp app_type: webapp
name: snowballtools-base-frontend name: deploy-frontend
app_version: $PACKAGE_VERSION app_version: $PACKAGE_VERSION
EOF EOF
echo "Files generated successfully." echo "Files generated successfully"
RECORD_FILE=records/application-record.yml RECORD_FILE=records/application-record.yml
@ -83,7 +57,7 @@ echo "ApplicationRecord published"
echo $RECORD_ID echo $RECORD_ID
# Set name to record # Set name to record
REGISTRY_APP_LRN="lrn://snowballtools/applications/snowballtools-base-frontend" REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/deploy-frontend"
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID"
@ -122,6 +96,45 @@ if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
exit 1 exit 1
fi fi
# Get payment address for deployer
paymentAddress=$(yarn --silent laconic -c config.yml registry name resolve "$DEPLOYER_LRN" | jq -r '.[0].attributes.paymentAddress')
paymentAmount=$(yarn --silent laconic -c config.yml registry name resolve "$DEPLOYER_LRN" | jq -r '.[0].attributes.minimumPayment' | sed 's/alnt//g')
# Pay deployer if paymentAmount is not null
if [[ -n "$paymentAmount" && "$paymentAmount" != "null" ]]; then
payment=$(yarn --silent laconic -c config.yml registry tokens send --address "$paymentAddress" --type alnt --quantity "$paymentAmount")
# Extract the transaction hash
txHash=$(echo "$payment" | jq -r '.tx.hash')
echo "Paid deployer with txHash as $txHash"
else
echo "Payment amount is null; skipping payment."
fi
# Generate application-deployment-request.yml
cat >./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: deploy-frontend@$PACKAGE_VERSION
application: lrn://$AUTHORITY/applications/deploy-frontend@$PACKAGE_VERSION
deployer: $DEPLOYER_LRN
dns: deploy
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.apps.vaasl.io
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liaet4yc0KX0iM1c
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"
repository_ref: $LATEST_HASH
payment: $txHash
EOF
RECORD_FILE=records/application-deployment-request.yml RECORD_FILE=records/application-deployment-request.yml
sleep 2 sleep 2

View File

@ -41,6 +41,7 @@ record:
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/test-progressive-web-app 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_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2 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_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball
LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc
LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073 LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073

View File

@ -1,17 +1,16 @@
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: "1.0.0" version: '1.0.0'
name: snowballtools-base-frontend@0.1.8 name: deploy-frontend@1.0.0
application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.8 application: lrn://vaasl/applications/deploy-frontend@1.0.0
dns: dashboard dns: deploy
config: config:
env: env:
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.apps.vaasl.io
LACONIC_HOSTED_CONFIG_app_github_clientid: b7c63b235ca1dd5639ab LACONIC_HOSTED_CONFIG_github_clientid: Ov23liaet4yc0KX0iM1c
LACONIC_HOSTED_CONFIG_app_github_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
LACONIC_HOSTED_CONFIG_app_github_pwa_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example LACONIC_HOSTED_CONFIG_wallet_connect_id: 63cad7ba97391f63652161f484670e15
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
meta: meta:
note: Added by Snowball @ Thu Apr 4 14:49:41 UTC 2024 note: Added by Snowball @ Thu Apr 4 14:49:41 UTC 2024
repository: "https://git.vdb.to/cerc-io/snowballtools-base" repository: "https://git.vdb.to/cerc-io/snowballtools-base"

View File

@ -4,5 +4,5 @@ record:
repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"] repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
app_type: webapp app_type: webapp
name: snowballtools-base-frontend name: deploy-frontend
app_version: 0.1.8 app_version: 1.0.0

View File

@ -15,3 +15,5 @@ VITE_BUGSNAG_API_KEY=
VITE_PASSKEY_WALLET_RPID= VITE_PASSKEY_WALLET_RPID=
VITE_TURNKEY_API_BASE_URL= VITE_TURNKEY_API_BASE_URL=
VITE_TURNKEY_ORGANIZATION_ID= VITE_TURNKEY_ORGANIZATION_ID=
VITE_LACONICD_CHAIN_ID=

View File

@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 3000", "dev": "vite --port 3000",
@ -30,10 +30,6 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7", "@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/material-tailwind-react-fork": "^2.1.10",
"@snowballtools/smartwallet-alchemy-light": "^0.2.0", "@snowballtools/smartwallet-alchemy-light": "^0.2.0",
"@snowballtools/types": "^0.2.0", "@snowballtools/types": "^0.2.0",

View File

@ -0,0 +1 @@
350e9ac2-8b27-4a79-9a82-78cfdb68ef71=0eacb7ae462f82c8b0199d28193b0bfa5265973dbb1fe991eec2cab737dfc1ec

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useStopwatch } from 'react-timer-hook'; import { useStopwatch } from 'react-timer-hook';
import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond'; import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond';
@ -12,14 +13,19 @@ const setStopWatchOffset = (time: string) => {
interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> { interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> {
offsetTimestamp: Date; offsetTimestamp: Date;
isPaused: boolean;
} }
const Stopwatch = ({ offsetTimestamp, ...props }: StopwatchProps) => { const Stopwatch = ({ offsetTimestamp, isPaused, ...props }: StopwatchProps) => {
const { totalSeconds } = useStopwatch({ const { totalSeconds, pause, start } = useStopwatch({
autoStart: true, autoStart: true,
offsetTimestamp: offsetTimestamp, offsetTimestamp: offsetTimestamp,
}); });
useEffect(() => {
isPaused ? pause() : start();
}, [isPaused]);
return <FormatMillisecond time={totalSeconds * 1000} {...props} />; return <FormatMillisecond time={totalSeconds * 1000} {...props} />;
}; };

View File

@ -1,7 +1,10 @@
import ConfirmDialog, { import ConfirmDialog, {
ConfirmDialogProps, ConfirmDialogProps,
} from 'components/shared/ConfirmDialog'; } from 'components/shared/ConfirmDialog';
import { ArrowRightCircleFilledIcon, LoadingIcon } from 'components/shared/CustomIcon'; import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
interface DeleteDeploymentDialogProps extends ConfirmDialogProps { interface DeleteDeploymentDialogProps extends ConfirmDialogProps {
isConfirmButtonLoading?: boolean; isConfirmButtonLoading?: boolean;
@ -20,7 +23,11 @@ export const DeleteDeploymentDialog = ({
dialogTitle="Delete deployment?" dialogTitle="Delete deployment?"
handleCancel={handleCancel} handleCancel={handleCancel}
open={open} open={open}
confirmButtonTitle={isConfirmButtonLoading ? "Deleting deployment" : "Yes, delete deployment"} confirmButtonTitle={
isConfirmButtonLoading
? 'Deleting deployment'
: 'Yes, delete deployment'
}
handleConfirm={handleConfirm} handleConfirm={handleConfirm}
confirmButtonProps={{ confirmButtonProps={{
variant: 'danger', variant: 'danger',

View File

@ -3,7 +3,11 @@ import { useForm, Controller } from 'react-hook-form';
import { FormProvider, FieldValues } from 'react-hook-form'; import { FormProvider, FieldValues } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMediaQuery } from 'usehooks-ts'; import { useMediaQuery } from 'usehooks-ts';
import { AddEnvironmentVariableInput, AuctionParams, Deployer } from 'gql-client'; import {
AddEnvironmentVariableInput,
AuctionParams,
Deployer,
} from 'gql-client';
import { Select, MenuItem, FormControl, FormHelperText } from '@mui/material'; import { Select, MenuItem, FormControl, FormHelperText } from '@mui/material';
@ -18,6 +22,8 @@ import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext'; import { useGQLClient } from '../../../context/GQLClientContext';
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 ConnectWallet from './ConnectWallet';
import { useWalletConnectClient } from 'context/WalletConnectContext';
type ConfigureDeploymentFormValues = { type ConfigureDeploymentFormValues = {
option: string; option: string;
@ -29,9 +35,17 @@ type ConfigureDeploymentFormValues = {
type ConfigureFormValues = ConfigureDeploymentFormValues & type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues; EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000';
const Configure = () => { const Configure = () => {
const { signClient, session, accounts } = useWalletConnectClient();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [deployers, setDeployers] = useState<Deployer[]>([]); 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 [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const templateId = searchParams.get('templateId'); const templateId = searchParams.get('templateId');
@ -51,7 +65,13 @@ const Configure = () => {
const client = useGQLClient(); const client = useGQLClient();
const methods = useForm<ConfigureFormValues>({ const methods = useForm<ConfigureFormValues>({
defaultValues: { option: 'Auction' }, defaultValues: {
option: 'Auction',
maxPrice: DEFAULT_MAX_PRICE,
lrn: '',
numProviders: 1,
variables: [],
},
}); });
const selectedOption = methods.watch('option'); const selectedOption = methods.watch('option');
@ -62,6 +82,8 @@ const Configure = () => {
const createProject = async ( const createProject = async (
data: FieldValues, data: FieldValues,
envVariables: AddEnvironmentVariableInput[], envVariables: AddEnvironmentVariableInput[],
senderAddress: string,
txHash: string,
): Promise<string> => { ): Promise<string> => {
setIsLoading(true); setIsLoading(true);
let projectId: string | null = null; let projectId: string | null = null;
@ -86,6 +108,8 @@ const Configure = () => {
owner, owner,
name, name,
isPrivate, isPrivate,
paymentAddress: senderAddress,
txHash,
}; };
const { addProjectFromTemplate } = await client.addProjectFromTemplate( const { addProjectFromTemplate } = await client.addProjectFromTemplate(
@ -105,6 +129,8 @@ const Configure = () => {
prodBranch: defaultBranch!, prodBranch: defaultBranch!,
repository: fullName!, repository: fullName!,
template: 'webapp', template: 'webapp',
paymentAddress: senderAddress,
txHash,
}, },
lrn, lrn,
auctionParams, auctionParams,
@ -132,56 +158,219 @@ const Configure = () => {
} }
}; };
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( const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => { async (createFormData: FieldValues) => {
const environmentVariables = createFormData.variables.map( try {
(variable: any) => { const deployerLrn = createFormData.lrn;
return { const deployer = deployers.find(
key: variable.key, (deployer) => deployer.deployerLrn === deployerLrn,
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( let amount: string;
createFormData, let senderAddress: string;
environmentVariables, let txHash: string;
); if (createFormData.option === 'LRN' && !deployer?.minimumPayment) {
toast({
id: 'no-payment-required',
title: 'No payment required. Deploying app...',
variant: 'info',
onDismiss: dismiss,
});
await client.getEnvironmentVariables(projectId); txHash = '';
senderAddress = '';
} else {
if (!selectedAccount) return;
if (templateId) { senderAddress = selectedAccount.split(':')[2];
createFormData.option === 'Auction'
? navigate( if (createFormData.option === 'LRN') {
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`, amount = deployer?.minimumPayment!;
) } else {
: navigate( amount = (
`/${orgSlug}/projects/create/template/deploy?projectId=${projectId}&templateId=${templateId}`, createFormData.numProviders * createFormData.maxPrice
).toString();
}
const amountToBePaid = amount.replace(/\D/g, '').toString();
const txHashResponse = await cosmosSendTokensHandler(
selectedAccount,
amountToBePaid,
); );
} else {
createFormData.option === 'Auction' if (!txHashResponse) {
? navigate( console.error('Tx not successful');
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`, return;
) }
: navigate(
`/${orgSlug}/projects/create/deploy?projectId=${projectId}`, txHash = txHashResponse;
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amountToBePaid.toString(),
); );
if (isTxHashValid === false) {
console.error('Invalid Tx hash', txHash);
return;
}
}
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) {
console.error(error);
toast({
id: 'error-deploying-app',
title: 'Error deploying app',
variant: 'error',
onDismiss: dismiss,
});
} }
}, },
[client, createProject, dismiss, toast], [client, createProject, dismiss, toast],
); );
const fetchDeployers = useCallback(async () => { const fetchDeployers = useCallback(async () => {
const res = await client.getDeployers() const res = await client.getDeployers();
setDeployers(res.deployers) setDeployers(res.deployers);
}, [client]) }, [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(() => { useEffect(() => {
fetchDeployers() fetchDeployers();
}, []) }, []);
return ( return (
<div className="space-y-7 px-4 py-6"> <div className="space-y-7 px-4 py-6">
@ -209,11 +398,18 @@ const Configure = () => {
<Select <Select
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
size='small' size="small"
displayEmpty displayEmpty
sx={{
fontFamily: 'inherit',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e0e0e0',
borderRadius: '8px',
},
}}
> >
<MenuItem value="LRN">Deployer LRN</MenuItem>
<MenuItem value="Auction">Create Auction</MenuItem> <MenuItem value="Auction">Create Auction</MenuItem>
<MenuItem value="LRN">Deployer LRN</MenuItem>
</Select> </Select>
)} )}
/> />
@ -238,17 +434,27 @@ const Configure = () => {
</span> </span>
<Select <Select
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => {
onChange(event.target.value);
onDeployerChange(event.target.value);
}}
displayEmpty displayEmpty
size='small' size="small"
> >
{deployers.map((deployer) => ( {deployers.map((deployer) => (
<MenuItem key={deployer.deployerLrn} value={deployer.deployerLrn}> <MenuItem
{deployer.deployerLrn} key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
{`${deployer.deployerLrn} ${deployer.minimumPayment ? `(${deployer.minimumPayment})` : ''}`}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
{fieldState.error && <FormHelperText>{fieldState.error.message}</FormHelperText>} {fieldState.error && (
<FormHelperText>
{fieldState.error.message}
</FormHelperText>
)}
</FormControl> </FormControl>
)} )}
/> />
@ -273,7 +479,11 @@ const Configure = () => {
control={methods.control} control={methods.control}
rules={{ required: true }} rules={{ required: true }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input type="number" value={value} onChange={onChange} /> <Input
type="number"
value={value}
onChange={(e) => onChange(e)}
/>
)} )}
/> />
</div> </div>
@ -300,22 +510,57 @@ const Configure = () => {
<EnvironmentVariablesForm /> <EnvironmentVariablesForm />
</div> </div>
<div> {selectedOption === 'LRN' && !selectedDeployer?.minimumPayment ? (
<Button <div>
{...buttonSize} <Button
type="submit" {...buttonSize}
disabled={isLoading} type="submit"
rightIcon={ disabled={isLoading || !selectedDeployer || !selectedAccount}
isLoading ? ( rightIcon={
<LoadingIcon className="animate-spin" /> isLoading ? (
) : ( <LoadingIcon className="animate-spin" />
<ArrowRightCircleFilledIcon /> ) : (
) <ArrowRightCircleFilledIcon />
} )
> }
{isLoading ? 'Deploying repo' : 'Deploy repo'} >
</Button> {isLoading ? 'Deploying' : 'Deploy'}
</div> </Button>
</div>
) : (
<>
<Heading as="h4" className="md:text-lg font-medium mb-3">
Connect to your wallet
</Heading>
<ConnectWallet onAccountChange={onAccountChange} />
{accounts && accounts?.length > 0 && (
<div>
<Button
{...buttonSize}
type="submit"
disabled={
isLoading || isPaymentLoading || !selectedAccount
}
rightIcon={
isLoading || isPaymentLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{!isPaymentDone
? isPaymentLoading
? 'Transaction Requested'
: 'Pay and Deploy'
: isLoading
? 'Deploying'
: 'Deploy'}
</Button>
</div>
)}
</>
)}
</form> </form>
</FormProvider> </FormProvider>
</div> </div>

View File

@ -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 (
<div className="p-4 bg-slate-100 rounded-lg mb-6">
{!accounts ? (
<div>
<Button type={'button'} onClick={handleConnect}>
Connect Wallet
</Button>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0].address}
onChange={(value) => {
value && onAccountChange(value);
}}
>
{accounts.map((account, index) => (
<Option key={index} value={account.address}>
{account.address.split(':').slice(1).join(':')}
</Option>
))}
</Select>
</div>
)}
</div>
);
};
export default ConnectWallet;

View File

@ -1,5 +1,7 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import axios from 'axios';
import { Deployment } from 'gql-client';
import { DeployStep, DeployStatus } from './DeployStep'; import { DeployStep, DeployStatus } from './DeployStep';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
@ -7,13 +9,37 @@ import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button'; import { Button } from '../../shared/Button';
import { ClockOutlineIcon, WarningIcon } from '../../shared/CustomIcon'; import { ClockOutlineIcon, WarningIcon } from '../../shared/CustomIcon';
import { CancelDeploymentDialog } from '../../projects/Dialog/CancelDeploymentDialog'; import { CancelDeploymentDialog } from '../../projects/Dialog/CancelDeploymentDialog';
import { useGQLClient } from 'context/GQLClientContext';
const FETCH_DEPLOYMENTS_INTERVAL = 5000;
type RequestState =
| 'SUBMITTED'
| 'DEPLOYING'
| 'DEPLOYED'
| 'REMOVED'
| 'CANCELLED'
| 'ERROR';
type Record = {
id: string;
createTime: string;
app: string;
lastState: RequestState;
lastUpdate: string;
logAvailable: boolean;
};
const TIMEOUT_DURATION = 5000;
const Deploy = () => { const Deploy = () => {
const client = useGQLClient();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId'); const projectId = searchParams.get('projectId');
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [deployment, setDeployment] = useState<Deployment>();
const [record, setRecord] = useState<Record>();
const handleOpen = () => setOpen(!open); const handleOpen = () => setOpen(!open);
const navigate = useNavigate(); const navigate = useNavigate();
@ -23,13 +49,67 @@ const Deploy = () => {
navigate(`/${orgSlug}/projects/create`); navigate(`/${orgSlug}/projects/create`);
}, []); }, []);
useEffect(() => { const isDeploymentFailed = useMemo(() => {
const timerID = setTimeout(() => { if (!record) {
navigate(`/${orgSlug}/projects/create/success/${projectId}`); return false;
}, TIMEOUT_DURATION); }
return () => clearInterval(timerID); // Not checking for `REMOVED` status as this status is received for a brief period before receiving `DEPLOYED` status
}, []); if (record.lastState === 'CANCELLED' || record.lastState === 'ERROR') {
return true;
} else {
return false;
}
}, [record]);
const fetchDeploymentRecords = useCallback(async () => {
if (!deployment) {
return;
}
try {
const response = await axios.get(
`${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`,
);
const record: Record = response.data;
setRecord(record);
} catch (err: any) {
console.log('Error fetching data from deployer', err);
}
}, [deployment]);
const fetchDeployment = useCallback(async () => {
if (!projectId) {
return;
}
const { deployments } = await client.getDeployments(projectId);
setDeployment(deployments[0]);
}, [client, projectId]);
useEffect(() => {
fetchDeployment();
fetchDeploymentRecords();
const interval = setInterval(() => {
fetchDeploymentRecords();
}, FETCH_DEPLOYMENTS_INTERVAL);
return () => {
clearInterval(interval);
};
}, [fetchDeployment, fetchDeploymentRecords]);
useEffect(() => {
if (!record) {
return;
}
if (record.lastState === 'DEPLOYED') {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}
}, [record]);
return ( return (
<div className="space-y-7"> <div className="space-y-7">
@ -42,6 +122,7 @@ const Deploy = () => {
<ClockOutlineIcon size={16} className="text-elements-mid-em" /> <ClockOutlineIcon size={16} className="text-elements-mid-em" />
<Stopwatch <Stopwatch
offsetTimestamp={setStopWatchOffset(Date.now().toString())} offsetTimestamp={setStopWatchOffset(Date.now().toString())}
isPaused={isDeploymentFailed}
/> />
</div> </div>
</div> </div>
@ -60,30 +141,36 @@ const Deploy = () => {
/> />
</div> </div>
<div> {!isDeploymentFailed ? (
<DeployStep <div>
title="Building" <DeployStep
status={DeployStatus.COMPLETE} title={record ? 'Submitted' : 'Submitting'}
step="1" status={record ? DeployStatus.COMPLETE : DeployStatus.PROCESSING}
processTime="72000" step="1"
/> />
<DeployStep
title="Deployment summary" <DeployStep
status={DeployStatus.PROCESSING} title={
step="2" record && record.lastState === 'DEPLOYED'
startTime={Date.now().toString()} ? 'Deployed'
/> : 'Deploying'
<DeployStep }
title="Running checks" status={
status={DeployStatus.NOT_STARTED} !record
step="3" ? DeployStatus.NOT_STARTED
/> : record.lastState === 'DEPLOYED'
<DeployStep ? DeployStatus.COMPLETE
title="Assigning domains" : DeployStatus.PROCESSING
status={DeployStatus.NOT_STARTED} }
step="4" step="2"
/> startTime={Date.now().toString()}
</div> />
</div>
) : (
<div>
<DeployStep title={record!.lastState} status={DeployStatus.ERROR} />
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,27 +1,16 @@
import { useState } from 'react';
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import FormatMillisecond from '../../FormatMilliSecond';
import processLogs from '../../../assets/process-logs.json';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
import { import {
CheckRoundFilledIcon, CheckRoundFilledIcon,
ClockOutlineIcon, ClockOutlineIcon,
CopyIcon,
LoaderIcon, LoaderIcon,
MinusCircleIcon,
PlusIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
import { useIntersectionObserver } from 'usehooks-ts';
enum DeployStatus { enum DeployStatus {
PROCESSING = 'progress', PROCESSING = 'progress',
COMPLETE = 'complete', COMPLETE = 'complete',
NOT_STARTED = 'notStarted', NOT_STARTED = 'notStarted',
ERROR = 'error',
} }
interface DeployStepsProps { interface DeployStepsProps {
@ -32,35 +21,11 @@ interface DeployStepsProps {
processTime?: string; processTime?: string;
} }
const DeployStep = ({ const DeployStep = ({ step, status, title, startTime }: DeployStepsProps) => {
step,
status,
title,
startTime,
processTime,
}: DeployStepsProps) => {
const [isOpen, setIsOpen] = useState(false);
const { toast, dismiss } = useToast();
const { isIntersecting: hideGradientOverlay, ref } = useIntersectionObserver({
threshold: 1,
});
const disableCollapse = status !== DeployStatus.COMPLETE;
return ( return (
<div className="border-b border-border-separator"> <div className="border-b border-border-separator">
{/* Collapisble trigger */}
<button <button
className={cn( className={cn('flex justify-between w-full py-5 gap-2', 'cursor-auto')}
'flex justify-between w-full py-5 gap-2',
disableCollapse && 'cursor-auto',
)}
tabIndex={disableCollapse ? -1 : undefined}
onClick={() => {
if (!disableCollapse) {
setIsOpen((val) => !val);
}
}}
> >
<div className={cn('grow flex items-center gap-3')}> <div className={cn('grow flex items-center gap-3')}>
{/* Icon */} {/* Icon */}
@ -73,12 +38,6 @@ const DeployStep = ({
{status === DeployStatus.PROCESSING && ( {status === DeployStatus.PROCESSING && (
<LoaderIcon className="animate-spin text-elements-link" /> <LoaderIcon className="animate-spin text-elements-link" />
)} )}
{status === DeployStatus.COMPLETE && (
<div className="text-controls-primary">
{!isOpen && <PlusIcon size={24} />}
{isOpen && <MinusCircleIcon size={24} />}
</div>
)}
</div> </div>
{/* Title */} {/* Title */}
@ -96,7 +55,10 @@ const DeployStep = ({
{status === DeployStatus.PROCESSING && ( {status === DeployStatus.PROCESSING && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<ClockOutlineIcon size={16} className="text-elements-low-em" /> <ClockOutlineIcon size={16} className="text-elements-low-em" />
<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} /> <Stopwatch
offsetTimestamp={setStopWatchOffset(startTime!)}
isPaused={false}
/>
</div> </div>
)} )}
{status === DeployStatus.COMPLETE && ( {status === DeployStatus.COMPLETE && (
@ -107,51 +69,9 @@ const DeployStep = ({
size={15} size={15}
/> />
</div> </div>
<FormatMillisecond time={Number(processTime)} />{' '}
</div> </div>
)} )}
</button> </button>
{/* Collapsible */}
<Collapse open={isOpen}>
<div className="relative text-xs text-elements-low-em h-36 overflow-y-auto">
{/* Logs */}
{processLogs.map((log, key) => {
return (
<p className="font-mono" key={key}>
{log}
</p>
);
})}
{/* End of logs ref used for hiding gradient overlay */}
<div ref={ref} />
{/* Overflow gradient overlay */}
{!hideGradientOverlay && (
<div className="h-14 w-full sticky bottom-0 inset-x-0 bg-gradient-to-t from-white to-transparent" />
)}
{/* Copy log button */}
<div className={cn('sticky bottom-4 left-1/2 flex justify-center')}>
<Button
size="xs"
onClick={() => {
navigator.clipboard.writeText(processLogs.join('\n'));
toast({
title: 'Logs copied',
variant: 'success',
id: 'logs',
onDismiss: dismiss,
});
}}
leftIcon={<CopyIcon size={16} />}
>
Copy log
</Button>
</div>
</div>
</Collapse>
</div> </div>
); );
}; };

View File

@ -12,6 +12,7 @@ import {
DialogTitle, DialogTitle,
DialogContent, DialogContent,
DialogActions, DialogActions,
Tooltip,
} from '@mui/material'; } from '@mui/material';
import { Avatar } from 'components/shared/Avatar'; import { Avatar } from 'components/shared/Avatar';
@ -92,28 +93,38 @@ const DeploymentDetailsCard = ({
}; };
const fetchDeploymentLogs = async () => { const fetchDeploymentLogs = async () => {
let url = `${deployment.deployer.deployerApiUrl}/log/${deployment.applicationDeploymentRequestId}`; setDeploymentLogs('Loading logs...');
const res = await fetch(url, { cache: 'no-store' });
handleOpenDialog(); handleOpenDialog();
if (res.ok) { const statusUrl = `${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`;
const logs = await res.text(); const statusRes = await fetch(statusUrl, { cache: 'no-store' }).then(
setDeploymentLogs(logs); (res) => res.json(),
);
if (!statusRes.logAvailable) {
setDeploymentLogs(statusRes.lastState);
} else {
const logsUrl = `${deployment.deployer.deployerApiUrl}/log/${deployment.applicationDeploymentRequestId}`;
const logsRes = await fetch(logsUrl, { cache: 'no-store' }).then((res) =>
res.text(),
);
setDeploymentLogs(logsRes);
} }
}; };
const renderDeploymentStatus = useCallback( const renderDeploymentStatus = useCallback(
(className?: string) => { (className?: string) => {
return ( return (
<div className={className}> <Tooltip title="Click to view build logs">
<Tag <div className={className} style={{ cursor: 'pointer' }}>
leftIcon={getIconByDeploymentStatus(deployment.status)} <Tag
size="xs" leftIcon={getIconByDeploymentStatus(deployment.status)}
type={STATUS_COLORS[deployment.status] ?? 'neutral'} size="xs"
onClick={fetchDeploymentLogs} type={STATUS_COLORS[deployment.status] ?? 'neutral'}
> onClick={fetchDeploymentLogs}
{deployment.status} >
</Tag> {deployment.status}
</div> </Tag>
</div>
</Tooltip>
); );
}, },
[deployment.status, deployment.commitHash], [deployment.status, deployment.commitHash],
@ -185,8 +196,8 @@ const DeploymentDetailsCard = ({
type="orange" type="orange"
initials={getInitials(deployment.createdBy.name ?? '')} initials={getInitials(deployment.createdBy.name ?? '')}
className="lg:size-5 2xl:size-6" className="lg:size-5 2xl:size-6"
// TODO: Add avatarUrl // TODO: Add avatarUrl
// imageSrc={deployment.createdBy.avatarUrl} // imageSrc={deployment.createdBy.avatarUrl}
></Avatar> ></Avatar>
</div> </div>
<OverflownText <OverflownText

View File

@ -56,7 +56,7 @@ export const DeploymentMenu = ({
const updateDeployment = async () => { const updateDeployment = async () => {
const isUpdated = await client.updateDeploymentToProd(deployment.id); const isUpdated = await client.updateDeploymentToProd(deployment.id);
if (isUpdated) { if (isUpdated.updateDeploymentToProd) {
await onUpdate(); await onUpdate();
toast({ toast({
id: 'deployment_changed_to_production', id: 'deployment_changed_to_production',
@ -77,7 +77,7 @@ export const DeploymentMenu = ({
const redeployToProd = async () => { const redeployToProd = async () => {
const isRedeployed = await client.redeployToProd(deployment.id); const isRedeployed = await client.redeployToProd(deployment.id);
setConfirmButtonLoadingLoading(false); setConfirmButtonLoadingLoading(false);
if (isRedeployed) { if (isRedeployed.redeployToProd) {
await onUpdate(); await onUpdate();
toast({ toast({
id: 'redeployed_to_production', id: 'redeployed_to_production',
@ -100,7 +100,7 @@ export const DeploymentMenu = ({
project.id, project.id,
deployment.id, deployment.id,
); );
if (isRollbacked) { if (isRollbacked.rollbackDeployment) {
await onUpdate(); await onUpdate();
toast({ toast({
id: 'deployment_rolled_back', id: 'deployment_rolled_back',
@ -124,7 +124,7 @@ export const DeploymentMenu = ({
setIsConfirmDeleteLoading(false); setIsConfirmDeleteLoading(false);
setDeleteDeploymentDialog((preVal) => !preVal); setDeleteDeploymentDialog((preVal) => !preVal);
if (isDeleted) { if (isDeleted.deleteDeployment) {
await onUpdate(); await onUpdate();
toast({ toast({
id: 'deployment_removal_requested', id: 'deployment_removal_requested',

View File

@ -45,27 +45,27 @@ export const AuctionCard = ({ project }: { project: Project }) => {
const result = await client.getAuctionData(project.auctionId); const result = await client.getAuctionData(project.auctionId);
setAuctionStatus(result.status); setAuctionStatus(result.status);
setAuctionDetails(result); setAuctionDetails(result);
setDeployers(project.deployers); }, [project.auctionId, project.deployers, project.fundsReleased]);
setFundsStatus(project.fundsReleased);
}, []); const fetchUpdatedProject = useCallback(async () => {
const updatedProject = await client.getProject(project.id);
setDeployers(updatedProject.project!.deployers!);
setFundsStatus(updatedProject.project!.fundsReleased!);
}, [project.id]);
const fetchData = useCallback(async () => {
await Promise.all([checkAuctionStatus(), fetchUpdatedProject()]);
}, [checkAuctionStatus, fetchUpdatedProject]);
useEffect(() => { useEffect(() => {
if (auctionStatus !== 'completed') { fetchData();
checkAuctionStatus();
const intervalId = setInterval(checkAuctionStatus, WAIT_DURATION);
return () => clearInterval(intervalId);
}
if (auctionStatus === 'completed') { const timerId = setInterval(() => {
const fetchUpdatedProject = async () => { fetchData();
// Wait for 5 secs since the project is not immediately updated with deployer LRNs }, WAIT_DURATION);
await new Promise((resolve) => setTimeout(resolve, WAIT_DURATION));
const updatedProject = await client.getProject(project.id); return () => clearInterval(timerId);
setDeployers(updatedProject.project?.deployers || []); }, [fetchData]);
};
fetchUpdatedProject();
}
}, [auctionStatus, client]);
const renderAuctionStatus = useCallback( const renderAuctionStatus = useCallback(
() => ( () => (
@ -136,7 +136,7 @@ export const AuctionCard = ({ project }: { project: Project }) => {
size="xs" size="xs"
type={fundsStatus ? 'positive' : 'emphasized'} type={fundsStatus ? 'positive' : 'emphasized'}
> >
{fundsStatus ? 'RELEASED' : 'LOCKED'} {fundsStatus ? 'RELEASED' : 'WAITING'}
</Tag> </Tag>
</div> </div>
</div> </div>

View File

@ -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 { 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 }[] | undefined;
}
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(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;
}
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

@ -2,7 +2,7 @@ import { ReactNode } from 'react';
import assert from 'assert'; import assert from 'assert';
import { SiweMessage, generateNonce } from 'siwe'; import { SiweMessage, generateNonce } from 'siwe';
import { WagmiProvider } from 'wagmi'; import { WagmiProvider } from 'wagmi';
import { arbitrum, mainnet } from 'wagmi/chains'; import { mainnet } from 'wagmi/chains';
import axios from 'axios'; import axios from 'axios';
import { createWeb3Modal } from '@web3modal/wagmi/react'; import { createWeb3Modal } from '@web3modal/wagmi/react';
@ -31,12 +31,12 @@ const axiosInstance = axios.create({
withCredentials: true, withCredentials: true,
}); });
const metadata = { const metadata = {
name: 'Web3Modal', name: 'Deploy App Auth',
description: 'Snowball Web3Modal', description: '',
url: window.location.origin, url: window.location.origin,
icons: ['https://avatars.githubusercontent.com/u/37784886'], icons: ['https://avatars.githubusercontent.com/u/37784886'],
}; };
const chains = [mainnet, arbitrum] as const; const chains = [mainnet] as const;
const config = defaultWagmiConfig({ const config = defaultWagmiConfig({
chains, chains,
projectId: VITE_WALLET_CONNECT_ID, projectId: VITE_WALLET_CONNECT_ID,

View File

@ -16,6 +16,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';
console.log(`v-0.0.9`); console.log(`v-0.0.9`);
@ -31,14 +32,16 @@ const gqlClient = new GQLClient({ gqlEndpoint });
root.render( root.render(
<LogErrorBoundary> <LogErrorBoundary>
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <WalletConnectClientProvider>
<Web3ModalProvider> <ThemeProvider>
<GQLClientProvider client={gqlClient}> <Web3ModalProvider>
<App /> <GQLClientProvider client={gqlClient}>
<Toaster /> <App />
</GQLClientProvider> <Toaster />
</Web3ModalProvider> </GQLClientProvider>
</ThemeProvider> </Web3ModalProvider>
</ThemeProvider>
</WalletConnectClientProvider>
</React.StrictMode> </React.StrictMode>
</LogErrorBoundary>, </LogErrorBoundary>,
); );

View File

@ -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 (
<div className="dark:bg-background">
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="w-16 h-16 p-2 dark:bg-overlay bg-sky-100 rounded-[800px] justify-center items-center gap-2 inline-flex">
<KeyIcon />
</div>
<div>
<div className="self-stretch text-center dark:text-foreground text-sky-950 text-2xl font-medium font-display leading-loose">
Create a passkey
</div>
<div className="text-center dark:text-foreground-secondary text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Passkeys allow you to sign in securely without using passwords.
</div>
</div>
</div>
<WavyBorder className="self-stretch" variant="stroke" />
<div className="p-6 flex-col justify-center items-center gap-8 inline-flex">
<div className="self-stretch h-36 flex-col justify-center items-center gap-2 flex">
<div className="self-stretch h-[72px] flex-col justify-start items-start gap-2 flex">
<div className="self-stretch h-5 px-1 flex-col justify-start items-start gap-1 flex">
<div className="self-stretch dark:text-foreground text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
Give it a name
</div>
</div>
<Input
value={name}
onInput={(e: any) => {
setName(e.target.value);
}}
/>
</div>
{auth.state.error ? (
<InlineNotification
title={auth.state.error.message}
variant="danger"
/>
) : (
<InlineNotification
title={`Once you press the "Create passkeys" button, you'll receive a prompt to create the passkey.`}
variant="info"
/>
)}
</div>
<Button
rightIcon={
loading ? <LoaderIcon className="animate-spin" /> : <IconRight />
}
className="self-stretch"
disabled={!name || loading}
onClick={createPasskey}
>
Create Passkey
</Button>
</div>
</div>
);
};

View File

@ -40,6 +40,8 @@ const deployment: Deployment = {
deployerApiUrl: 'https://webapp-deployer-api.example.com', deployerApiUrl: 'https://webapp-deployer-api.example.com',
deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu', deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu',
deployerLrn: 'lrn://example/deployers/webapp-deployer-api.example.com', deployerLrn: 'lrn://example/deployers/webapp-deployer-api.example.com',
minimumPayment: '1000alnt',
baseDomain: 'pwa.example.com',
}, },
status: DeploymentStatus.Ready, status: DeploymentStatus.Ready,
createdBy: { createdBy: {

View File

@ -93,13 +93,19 @@ const Id = () => {
OPEN REPO OPEN REPO
</Button> </Button>
</Link> </Link>
<Button {project.deployments.length > 0 && (
{...buttonSize} <Link
className="h-11 transition-colors" to={`https://${project.name.toLowerCase()}.${project.deployments[0].deployer.baseDomain}`}
shape="default" >
> <Button
GO TO APP {...buttonSize}
</Button> className="h-11 transition-colors"
shape="default"
>
GO TO APP
</Button>
</Link>
)}
</div> </div>
</div> </div>
<WavyBorder /> <WavyBorder />

View File

@ -14,7 +14,6 @@ import {
ArrowRightCircleFilledIcon, ArrowRightCircleFilledIcon,
LoadingIcon, LoadingIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Checkbox } from 'components/shared/Checkbox';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast'; import { useToast } from 'components/shared/Toast';
@ -42,6 +41,17 @@ const CreateRepo = () => {
const [gitAccounts, setGitAccounts] = useState<string[]>([]); const [gitAccounts, setGitAccounts] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const checkRepoExists = async (account: string, repoName: string) => {
try {
await octokit.rest.repos.get({ owner: account, repo: repoName });
return true;
} catch (error) {
// Error handled by octokit error hook interceptor in Octokit context
console.error(error);
return;
}
};
const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback( const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback(
async (data) => { async (data) => {
assert(data.account); assert(data.account);
@ -51,6 +61,18 @@ const CreateRepo = () => {
assert(template.repoFullName, 'Template URL not provided'); assert(template.repoFullName, 'Template URL not provided');
const [owner, repo] = template.repoFullName.split('/'); const [owner, repo] = template.repoFullName.split('/');
const repoExists = await checkRepoExists(data.account, data.repoName);
if (repoExists) {
toast({
id: 'repo-exist-error',
title: 'Repository already exists with this name',
variant: 'warning',
onDismiss: dismiss,
});
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
navigate( navigate(
@ -82,7 +104,7 @@ const CreateRepo = () => {
}); });
} }
}, },
[octokit], [octokit, toast],
); );
useEffect(() => { useEffect(() => {
@ -169,15 +191,6 @@ const CreateRepo = () => {
)} )}
/> />
</div> </div>
<div>
<Controller
name="isPrivate"
control={control}
render={({}) => (
<Checkbox label="Make this repo private" disabled={true} />
)}
/>
</div>
<div> <div>
<Button <Button
{...buttonSize} {...buttonSize}

View File

@ -129,16 +129,16 @@ const OverviewTabPanel = () => {
<Heading className="text-lg leading-6 font-medium truncate dark:text-foreground"> <Heading className="text-lg leading-6 font-medium truncate dark:text-foreground">
{project.name} {project.name}
</Heading> </Heading>
{project.baseDomains && {project.deployments &&
project.baseDomains.length > 0 && project.deployments.length > 0 &&
project.baseDomains.map((baseDomain, index) => ( project.deployments.map((deployment, index) => (
<p> <p>
<a <a
key={index} key={index}
href={`https://${project.name.toLowerCase()}.${baseDomain}`} href={`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}
className="text-sm text-elements-low-em dark:text-foreground tracking-tight truncate" className="text-sm text-elements-low-em dark:text-foreground tracking-tight truncate"
> >
{baseDomain} {deployment.deployer.baseDomain}
</a> </a>
</p> </p>
))} ))}
@ -180,14 +180,20 @@ const OverviewTabPanel = () => {
{/* DEPLOYMENT */} {/* DEPLOYMENT */}
<OverviewInfo label="Deployment URL" icon={<CursorBoxIcon />}> <OverviewInfo label="Deployment URL" icon={<CursorBoxIcon />}>
<div className="flex gap-2 items-center"> {project.deployments &&
<Link to="#"> project.deployments.length > 0 &&
<span className="text-controls-primary dark:text-foreground group hover:border-controls-primary transition-colors border-b border-b-transparent flex gap-2 items-center text-sm tracking-tight"> project.deployments.map((deployment) => (
{liveDomain?.name}{' '} <div className="flex gap-2 items-center">
<LinkIcon className="group-hover:rotate-45 transition-transform" /> <Link
</span> to={`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}
</Link> >
</div> <span className="text-controls-primary dark:text-foreground group hover:border-controls-primary transition-colors border-b border-b-transparent flex gap-2 items-center text-sm tracking-tight">
{`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}
<LinkIcon className="group-hover:rotate-45 transition-transform" />
</span>
</Link>
</div>
))}
</OverviewInfo> </OverviewInfo>
{/* DEPLOYMENT DATE */} {/* DEPLOYMENT DATE */}

View File

@ -106,6 +106,8 @@ export const deployment0: Deployment = {
deployerApiUrl: 'https://webapp-deployer-api.example.com', deployerApiUrl: 'https://webapp-deployer-api.example.com',
deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu', deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu',
deployerLrn: 'lrn://deployer.apps.snowballtools.com ', deployerLrn: 'lrn://deployer.apps.snowballtools.com ',
minimumPayment: '1000alnt',
baseDomain: 'pwa.example.com',
}, },
applicationDeploymentRequestId: applicationDeploymentRequestId:
'bafyreiaycvq6imoppnpwdve4smj6t6ql5svt5zl3x6rimu4qwyzgjorize', 'bafyreiaycvq6imoppnpwdve4smj6t6ql5svt5zl3x6rimu4qwyzgjorize',
@ -132,8 +134,12 @@ export const project: Project = {
deployerApiUrl: 'https://webapp-deployer-api.example.com', deployerApiUrl: 'https://webapp-deployer-api.example.com',
deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu', deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu',
deployerLrn: 'lrn://deployer.apps.snowballtools.com ', deployerLrn: 'lrn://deployer.apps.snowballtools.com ',
} minimumPayment: '1000alnt',
baseDomain: 'pwa.example.com',
},
], ],
paymentAddress: '0x657868687686rb4787987br8497298r79284797487',
txHash: '74btygeuydguygf838gcergurcbhuedbcjhu',
webhooks: ['beepboop'], webhooks: ['beepboop'],
icon: 'Icon', icon: 'Icon',
fundsReleased: true, fundsReleased: true,

View File

@ -17,7 +17,7 @@ const meta: Meta<typeof AddEnvironmentVariableRow> = {
pathParams: { userId: 'me' }, pathParams: { userId: 'me' },
}, },
routing: { routing: {
path: '/snowball-tools-1/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings', path: '/deploy-tools/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings',
}, },
}), }),
}, },

View File

@ -17,7 +17,7 @@ const meta: Meta<typeof Config> = {
pathParams: { userId: 'me' }, pathParams: { userId: 'me' },
}, },
routing: { routing: {
path: '/snowball-tools-1/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings/domains/add/config', path: '/deploy-tools/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings/domains/add/config',
}, },
}), }),
}, },

View File

@ -18,7 +18,7 @@ const meta: Meta<typeof DeleteProjectDialog> = {
pathParams: { userId: 'me' }, pathParams: { userId: 'me' },
}, },
routing: { routing: {
path: '/snowball-tools-1/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings', path: '/deploy-tools/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings',
}, },
}), }),
}, },

View File

@ -17,7 +17,7 @@ const meta: Meta<typeof SetupDomain> = {
pathParams: { userId: 'me' }, pathParams: { userId: 'me' },
}, },
routing: { routing: {
path: '/snowball-tools-1/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings/domains', path: '/deploy-tools/projects/6bb3bec2-d71b-4fc0-9e32-4767f68668f4/settings/domains',
}, },
}), }),
}, },

View File

@ -9,3 +9,4 @@ export const VITE_GITHUB_CLIENT_ID = import.meta.env.VITE_GITHUB_CLIENT_ID;
export const VITE_WALLET_CONNECT_ID = import.meta.env.VITE_WALLET_CONNECT_ID; 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;

View File

@ -1,34 +1,36 @@
import React from 'react'; // import React from 'react';
import Bugsnag from '@bugsnag/js'; // import Bugsnag from '@bugsnag/js';
import BugsnagPluginReact from '@bugsnag/plugin-react'; // import BugsnagPluginReact from '@bugsnag/plugin-react';
import BugsnagPerformance from '@bugsnag/browser-performance'; // import BugsnagPerformance from '@bugsnag/browser-performance';
import { VITE_BUGSNAG_API_KEY } from './constants'; // import { VITE_BUGSNAG_API_KEY } from './constants';
if (VITE_BUGSNAG_API_KEY) { // if (VITE_BUGSNAG_API_KEY) {
Bugsnag.start({ // Bugsnag.start({
apiKey: VITE_BUGSNAG_API_KEY, // apiKey: VITE_BUGSNAG_API_KEY,
plugins: [new BugsnagPluginReact()], // plugins: [new BugsnagPluginReact()],
}); // });
BugsnagPerformance.start({ apiKey: VITE_BUGSNAG_API_KEY }); // BugsnagPerformance.start({ apiKey: VITE_BUGSNAG_API_KEY });
} // }
export const errorLoggingEnabled = !!VITE_BUGSNAG_API_KEY; // export const errorLoggingEnabled = !!VITE_BUGSNAG_API_KEY;
export const LogErrorBoundary = VITE_BUGSNAG_API_KEY // export const LogErrorBoundary = VITE_BUGSNAG_API_KEY
? Bugsnag.getPlugin('react')!.createErrorBoundary(React) // ? Bugsnag.getPlugin('react')!.createErrorBoundary(React)
: ({ children }: any) => children; // : ({ children }: any) => children;
export function logError(error: Error) { // export function logError(error: Error) {
let errors: any[] = [error]; // let errors: any[] = [error];
let safety = 0; // let safety = 0;
while (errors[errors.length - 1].cause && safety < 10) { // while (errors[errors.length - 1].cause && safety < 10) {
errors.push('::caused by::', errors[errors.length - 1].cause); // errors.push('::caused by::', errors[errors.length - 1].cause);
safety += 1; // safety += 1;
} // }
console.error(...errors); // console.error(...errors);
if (VITE_BUGSNAG_API_KEY) { // if (VITE_BUGSNAG_API_KEY) {
Bugsnag.notify(error); // Bugsnag.notify(error);
} // }
} // }
export const LogErrorBoundary = ({ children }: any) => children;

View File

@ -1,48 +0,0 @@
import { SiweMessage } from 'siwe';
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
import { v4 as uuid } from 'uuid';
import { BASE_URL } from './constants';
const domain = window.location.host;
const origin = window.location.origin;
export async function signInWithEthereum(
chainId: number,
action: 'signup' | 'login',
wallet: PKPEthersWallet,
) {
const message = await createSiweMessage(
chainId,
await wallet.getAddress(),
'Sign in with Ethereum to the app.',
);
const signature = await wallet.signMessage(message);
const res = await fetch(`${BASE_URL}/auth/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action, message, signature }),
credentials: 'include',
});
return (await res.json()) as { success: boolean; error?: string };
}
async function createSiweMessage(
chainId: number,
address: string,
statement: string,
) {
const message = new SiweMessage({
domain,
address,
statement,
uri: origin,
version: '1',
chainId,
nonce: uuid().replace(/[^a-z0-9]/g, ''),
});
return message.prepareMessage();
}

View File

@ -1,33 +0,0 @@
import { useEffect, useState } from 'react';
import { Snowball, SnowballChain } from '@snowballtools/js-sdk';
import {
// LitAppleAuth,
LitGoogleAuth,
LitPasskeyAuth,
} from '@snowballtools/auth-lit';
import { VITE_LIT_RELAY_API_KEY } from './constants';
export const snowball = Snowball.withAuth({
google: LitGoogleAuth.configure({
litRelayApiKey: VITE_LIT_RELAY_API_KEY!,
}),
// apple: LitAppleAuth.configure({
// litRelayApiKey: VITE_LIT_RELAY_API_KEY!,
// }),
passkey: LitPasskeyAuth.configure({
litRelayApiKey: VITE_LIT_RELAY_API_KEY!,
}),
}).create({
initialChain: SnowballChain.sepolia,
});
export function useSnowball() {
const [state, setState] = useState(100);
useEffect(() => {
// Subscribe and directly return the unsubscribe function
return snowball.subscribe(() => setState(state + 1));
}, [state]);
return snowball;
}

View File

@ -0,0 +1,8 @@
import { WalletConnectModal } from '@walletconnect/modal';
import { VITE_WALLET_CONNECT_ID } from 'utils/constants';
export const walletConnectModal = new WalletConnectModal({
projectId: VITE_WALLET_CONNECT_ID,
chains: [],
});

View File

@ -4,6 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"main": "dist/index.js", "main": "dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./src",
"scripts": { "scripts": {
"build": "npx tsup src/index.ts --dts --format esm,cjs --sourcemap", "build": "npx tsup src/index.ts --dts --format esm,cjs --sourcemap",
"lint": "tsc --noEmit" "lint": "tsc --noEmit"

View File

@ -432,4 +432,25 @@ export class GQLClient {
return data; return data;
} }
async getAddress(): Promise<string> {
const { data } = await this.client.query({
query: queries.getAddress,
});
return data.address;
}
async verifyTx(txHash: string, amount: string, senderAddress: string): Promise<boolean> {
const { data } = await this.client.query({
query: queries.verifyTx,
variables: {
txHash,
amount,
senderAddress
}
});
return data.verifyTx;
}
} }

View File

@ -28,7 +28,10 @@ query ($projectId: String!) {
deployerLrn deployerLrn
deployerId deployerId
deployerApiUrl deployerApiUrl
minimumPayment
} }
paymentAddress
txHash
fundsReleased fundsReleased
framework framework
repository repository
@ -54,6 +57,9 @@ query ($projectId: String!) {
commitHash commitHash
createdAt createdAt
environment environment
deployer {
baseDomain
}
domain { domain {
status status
branch branch
@ -84,7 +90,10 @@ query ($organizationSlug: String!) {
deployerLrn deployerLrn
deployerId deployerId
deployerApiUrl deployerApiUrl
minimumPayment
} }
paymentAddress
txHash
fundsReleased fundsReleased
prodBranch prodBranch
webhooks webhooks
@ -148,6 +157,7 @@ query ($projectId: String!) {
deployerLrn deployerLrn
deployerId deployerId
deployerApiUrl deployerApiUrl
minimumPayment
} }
environment environment
isCurrent isCurrent
@ -211,7 +221,10 @@ query ($searchText: String!) {
deployerLrn deployerLrn
deployerId deployerId
deployerApiUrl deployerApiUrl
minimumPayment
} }
paymentAddress
txHash
fundsReleased fundsReleased
prodBranch prodBranch
webhooks webhooks
@ -314,6 +327,19 @@ query {
deployerLrn deployerLrn
deployerId deployerId
deployerApiUrl deployerApiUrl
minimumPayment
} }
} }
`; `;
export const getAddress = gql`
query {
address
}
`;
export const verifyTx = gql`
query ($txHash: String!, $amount: String!, $senderAddress: String!) {
verifyTx(txHash: $txHash, amount: $amount, senderAddress: $senderAddress)
}
`;

View File

@ -1,4 +1,3 @@
import { addProjectFromTemplate } from "./mutations";
// Note: equivalent to types present in GQL schema // Note: equivalent to types present in GQL schema
export enum Role { export enum Role {
@ -120,6 +119,8 @@ export type Deployer = {
deployerLrn: string; deployerLrn: string;
deployerId: string; deployerId: string;
deployerApiUrl: string; deployerApiUrl: string;
baseDomain: string;
minimumPayment: string | null;
} }
export type OrganizationMember = { export type OrganizationMember = {
@ -178,6 +179,8 @@ export type Project = {
framework: string; framework: string;
deployers: [Deployer] deployers: [Deployer]
auctionId: string; auctionId: string;
paymentAddress: string;
txHash: string;
fundsReleased: boolean; fundsReleased: boolean;
webhooks: string[]; webhooks: string[];
members: ProjectMember[]; members: ProjectMember[];
@ -307,6 +310,8 @@ export type AddProjectFromTemplateInput = {
owner: string; owner: string;
name: string; name: string;
isPrivate: boolean; isPrivate: boolean;
paymentAddress: string;
txHash: string;
}; };
export type AddProjectInput = { export type AddProjectInput = {
@ -314,6 +319,8 @@ export type AddProjectInput = {
repository: string; repository: string;
prodBranch: string; prodBranch: string;
template?: string; template?: string;
paymentAddress: string;
txHash: string;
}; };
export type UpdateProjectInput = { export type UpdateProjectInput = {

1361
yarn.lock

File diff suppressed because it is too large Load Diff