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
This commit is contained in:
2024-10-28 09:46:18 +00:00
co-authored by IshaVenikar Shreerang Kale
parent b449c299dc
commit 63969ae25a
34 changed files with 752 additions and 1539 deletions
@@ -41,6 +41,3 @@
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"
[misc]
projectDomain = "apps.snowballtools.com"
-5
View File
@@ -51,17 +51,12 @@ export interface AuctionConfig {
denom: string;
}
export interface MiscConfig {
projectDomain: string;
}
export interface Config {
server: ServerConfig;
database: DatabaseConfig;
gitHub: GitHubConfig;
registryConfig: RegistryConfig;
auction: AuctionConfig;
misc: MiscConfig;
turnkey: {
apiBaseUrl: string;
apiPublicKey: string;
+1 -1
View File
@@ -13,7 +13,7 @@ import assert from 'assert';
import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig, MiscConfig } from './config';
import { DatabaseConfig } from './config';
import { User } from './entity/User';
import { Organization } from './entity/Organization';
import { Project } from './entity/Project';
+3
View File
@@ -15,6 +15,9 @@ export class Deployer {
@Column('varchar')
baseDomain!: string;
@Column('varchar', { nullable: true })
minimumPayment!: string | null;
@ManyToMany(() => Project, (project) => project.deployers)
projects!: Project[];
}
+6
View File
@@ -66,6 +66,12 @@ export class Project {
@Column('varchar', { nullable: true })
framework!: string | null;
@Column('varchar')
paymentAddress!: string;
@Column('varchar')
txHash!: string;
@Column({
type: 'simple-array'
})
+1 -1
View File
@@ -17,7 +17,7 @@ const log = debug('snowball:server');
const OAUTH_CLIENT_TYPE = 'oauth-app';
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({
clientType: OAUTH_CLIENT_TYPE,
+32 -1
View File
@@ -5,7 +5,8 @@ import { Octokit } from 'octokit';
import { inc as semverInc } from 'semver';
import { DeepPartial } from 'typeorm';
import { Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk';
import { Account, Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk';
import { IndexedTx } from '@cosmjs/stargate';
import { RegistryConfig } from './config';
import {
@@ -483,6 +484,36 @@ export class Registry {
return this.registry.getAuctionsByIds([auctionId]);
}
async sendTokensToAccount(receiverAddress: string, amount: string): Promise<any> {
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees);
await registryTransactionWithRetry(() =>
this.registry.sendCoins(
{
amount,
denom: 'alnt',
destinationAddress: receiverAddress
},
this.registryConfig.privateKey,
fee
)
);
}
async getAccount(): Promise<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 {
assert(this.registryConfig.authority, "Authority doesn't exist");
return `lrn://${this.registryConfig.authority}/applications/${appName}`;
+16 -1
View File
@@ -80,6 +80,21 @@ export const createResolvers = async (service: Service): Promise<any> => {
deployers: async (_: any, __: any, context: any) => {
return service.getDeployers();
},
address: async (_: any, __: any, context: any) => {
return service.getAddress();
},
verifyTx: async (
_: any,
{
txHash,
amount,
senderAddress,
}: { txHash: string; amount: string; senderAddress: string },
) => {
return service.verifyTx(txHash, amount, senderAddress);
},
},
// TODO: Return error in GQL response
@@ -221,7 +236,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
organizationSlug: string;
data: AddProjectFromTemplateInput;
lrn: string;
auctionParams: AuctionParams,
auctionParams: AuctionParams;
environmentVariables: EnvironmentVariables[];
},
context: any,
+9
View File
@@ -77,6 +77,8 @@ type Project {
fundsReleased: Boolean
template: String
framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!]
members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!]
@@ -137,6 +139,7 @@ type Deployer {
deployerLrn: String!
deployerId: String!
deployerApiUrl: String!
minimumPayment: String
createdAt: String!
updatedAt: String!
}
@@ -157,6 +160,8 @@ input AddProjectFromTemplateInput {
owner: String!
name: String!
isPrivate: Boolean!
paymentAddress: String!
txHash: String!
}
input AddProjectInput {
@@ -164,6 +169,8 @@ input AddProjectInput {
repository: String!
prodBranch: String!
template: String
paymentAddress: String!
txHash: String!
}
input UpdateProjectInput {
@@ -258,6 +265,8 @@ type Query {
getAuctionData(auctionId: String!): Auction!
domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
}
type Mutation {
+63 -1
View File
@@ -212,6 +212,9 @@ export class Service {
if (!deployment.project.fundsReleased) {
const fundsReleased = await this.releaseDeployerFundsByProjectId(deployment.projectId);
// Return remaining amount to owner
await this.returnUserFundsByProjectId(deployment.projectId, true);
await this.db.updateProjectById(deployment.projectId, {
fundsReleased,
});
@@ -309,6 +312,9 @@ export class Service {
if (!deployerRecords) {
log(`No winning deployer for auction ${project!.auctionId}`);
// Return all funds to the owner
await this.returnUserFundsByProjectId(project.id, false)
} else {
const deployers = await this.saveDeployersByDeployerRecords(deployerRecords);
for (const deployer of deployers) {
@@ -829,6 +835,8 @@ export class Service {
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
paymentAddress: data.paymentAddress,
txHash: data.txHash
}, lrn, auctionParams, environmentVariables);
if (!project || !project.id) {
@@ -1324,6 +1332,30 @@ export class Service {
return false;
}
async returnUserFundsByProjectId(projectId: string, winningDeployersPresent: boolean) {
const project = await this.db.getProjectById(projectId);
if (!project || !project.auctionId) {
log(`Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}`);
return false;
}
const auction = await this.getAuctionData(project.auctionId);
let amountToBeReturned;
if (winningDeployersPresent) {
amountToBeReturned = auction.winnerPrice * auction.numProviders;
} else {
amountToBeReturned = auction.maxPrice * auction.numProviders;
}
await this.laconicRegistry.sendTokensToAccount(
project.paymentAddress,
amountToBeReturned.toString()
);
}
async getDeployers(): Promise<Deployer[]> {
const dbDeployers = await this.db.getDeployers();
@@ -1352,13 +1384,15 @@ export class Service {
const deployerId = record.id;
const deployerLrn = record.names[0];
const deployerApiUrl = record.attributes.apiUrl;
const minimumPayment = record.attributes.minimumPayment
const baseDomain = deployerApiUrl.substring(deployerApiUrl.indexOf('.') + 1);
const deployerData = {
deployerLrn,
deployerId,
deployerApiUrl,
baseDomain
baseDomain,
minimumPayment
};
// TODO: Update deployers table in a separate job
@@ -1369,4 +1403,32 @@ export class Service {
return deployers;
}
async getAddress(): Promise<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;
}
}
+3
View File
@@ -70,6 +70,8 @@ export interface AddProjectFromTemplateInput {
owner: string;
name: string;
isPrivate: boolean;
paymentAddress: string;
txHash: string;
}
export interface AuctionParams {
@@ -92,6 +94,7 @@ export interface DeployerRecord {
expiryTime: string;
attributes: {
apiUrl: string;
minimumPayment: string | null;
name: string;
paymentAddress: string;
publicKey: string;