@silvana-one/mina-prover
Version:
Silvana Mina Prover
614 lines (596 loc) • 18.9 kB
text/typescript
import chalk from "chalk";
import { sleep } from "@silvana-one/mina-utils";
import { LocalCloud, LocalStorage } from "../local/local.js";
import {
zkCloudWorker,
Cloud,
JobData,
config,
blockchain,
} from "@silvana-one/prover";
const { ZKCLOUDWORKER_AUTH, ZKCLOUDWORKER_API } = config;
/**
* The APICommand type for interacting with the zkCloudWorker
* @typedef { "recursiveProof" | "execute" | "sendTransactions" | "jobResult" | "deploy" | "getBalance" | "queryBilling" } ApiCommand
* @property recursiveProof The command for the recursiveProof calculation
* @property execute The command for the execute function call (sync or async)
* @property sendTransactions The command for sending transactions to the cloud
* @property jobResult The command for getting the result of the job
* @property deploy The command for deploying the code to the cloud, it is recommended use CLI tools for deployment
* @property getBalance The command for getting the balance of the user's account with zkCloudWorker
* @property queryBilling The command for getting the billing report of the user's account with zkCloudWorker
*/
export type ApiCommand =
| "recursiveProof"
| "execute"
| "sendTransactions"
| "jobResult"
| "deploy"
| "getBalance"
| "queryBilling";
/**
* API class for interacting with the zkCloudWorker
* @property jwt The jwt token for authentication, get it at https://t.me/minanft_bot?start=auth
* @property endpoint The endpoint of the serverless api
* @property chain The blockchain network to use
* @property webhook The webhook for the serverless api to get the results
* @property localWorker The local worker for the serverless api to test the code locally
*/
export class zkCloudWorkerClient {
readonly jwt: string;
readonly endpoint?: string;
readonly chain: blockchain;
readonly webhook?: string;
readonly localWorker?: (cloud: Cloud) => Promise<zkCloudWorker>;
/**
* Constructor for the API class
* @param params the parameters for the API class
* @param params.jwt The jwt token for authentication, get it at https://t.me/minanft_bot?start=auth
* @param params.zkcloudworker The local worker for the serverless api to test the code locally
* @param params.chain The blockchain network to use
* @param params.webhook The webhook for the serverless api to get the results
*/
constructor(params: {
jwt: string;
zkcloudworker?: (cloud: Cloud) => Promise<zkCloudWorker>;
chain?: blockchain;
webhook?: string;
}) {
const { jwt, zkcloudworker, webhook } = params;
this.jwt = jwt;
const chain = params.chain ?? "devnet";
this.chain = chain;
this.endpoint =
chain === "devnet" || chain === "zeko" || chain === "mainnet"
? ZKCLOUDWORKER_API + chain
: undefined;
this.webhook = webhook;
if (jwt === "local") {
if (zkcloudworker === undefined)
throw new Error("worker is required for local mode");
this.localWorker = zkcloudworker;
}
}
/**
* Starts a new job for the proof calculation using serverless api call
* @param data the data for the proof call
* @param data.developer the developer
* @param data.repo the repo to use
* @param data.transactions the transactions
* @param data.task the task of the job
* @param data.userId the userId of the job
* @param data.args the arguments of the job, should be serialized JSON or string
* @param data.metadata the metadata of the job, should be serialized JSON or string
* @param data.webhook the webhook for the job
* @returns { success: boolean, error?: string, jobId?: string }
* where jonId is the jobId of the job
*
* The developers repo should provide a zkcloudworker function
* that can be called with the given parameters, see the examples
*/
public async recursiveProof(data: {
developer: string;
repo: string;
transactions: string[];
task?: string;
userId?: string;
args?: string;
metadata?: string;
webhook?: string;
}): Promise<{
success: boolean;
error?: string;
jobId?: string;
}> {
const result = await this.apiHub("recursiveProof", data);
if (
result.data === "error" ||
(typeof result.data === "string" && result.data.startsWith("error"))
)
return {
success: false,
error: result.error,
};
else if (result.success === false || result.data?.success === false)
return {
success: false,
error:
result.error ?? result.data?.error ?? "recursiveProof call failed",
};
else if (
result.success === true &&
result.data?.success === true &&
result.data?.jobId !== undefined
)
return {
success: result.success,
jobId: result.data.jobId,
error: result.error,
};
else
return {
success: false,
error: "recursiveProof call error",
};
}
/**
* Starts a new job for the function call using serverless api call
* @param data the data for the proof call
* @param data.developer the developer
* @param data.repo the repo to use
* @param data.transactions the transactions
* @param data.task the task of the job
* @param data.userId the userId of the job
* @param data.args the arguments of the job
* @param data.metadata the metadata of the job
* @param data.mode the mode of the job execution: "sync" will not create a job, it will execute the function synchronously within 30 seconds and with the memory limit of 256 MB
* @returns { success: boolean, error?: string, jobId?: string, result?: any }
* where jonId is the jobId of the job (for async calls), result is the result of the job (for sync calls)
*/
public async execute(data: {
developer: string;
repo: string;
transactions: string[];
task: string;
userId?: string;
args?: string;
metadata?: string;
mode?: string;
}): Promise<{
success: boolean;
error?: string;
jobId?: string;
result?: any;
}> {
const result = await this.apiHub("execute", data);
if (
result.data === "error" ||
(typeof result.data === "string" && result.data.startsWith("error"))
)
return {
success: false,
error: result.error,
};
else if (result.success === false || result.data?.success === false)
return {
success: false,
error: result.error ?? result.data?.error ?? "execute call failed",
};
else if (
result.success === true &&
data.mode === "sync" &&
result.data !== undefined
)
return {
success: result.success,
jobId: undefined,
result: result.data,
error: result.error,
};
else if (
result.success === true &&
data.mode !== "sync" &&
result.data?.success === true &&
result.data?.jobId !== undefined
)
return {
success: result.success,
jobId: result.data.jobId,
result: undefined,
error: result.error,
};
else
return {
success: false,
error: "execute call error",
};
}
/**
* Sends transactions to the blockchain using serverless api call
* @param data the data for the proof call
* @param data.developer the developer
* @param data.repo the repo to use
* @param data.transactions the transactions
* @returns { success: boolean, error?: string, txId?: string[] }
* where txId is the transaction id of the transaction, in the sequence of the input transactions
*/
public async sendTransactions(data: {
developer: string;
repo: string;
transactions: string[];
}): Promise<{
success: boolean;
error?: string;
txId?: string[];
}> {
const result = await this.apiHub("sendTransactions", data);
if (result.data === "error")
// TODO: check if this is correct in AWS code
return {
success: false,
error: result.error,
};
else
return {
success: result.success,
txId: result.data,
error: result.error,
};
}
/**
* Gets the result of the job using serverless api call
* @param data the data for the jobResult call
* @param data.jobId the jobId of the job
* @param data.includeLogs include logs in the result, default is false
* @returns { success: boolean, error?: string, result?: any }
* where result is the result of the job
* if the job is not finished yet, the result will be undefined
* if the job failed, the result will be undefined and error will be set
* if the job is finished, the result will be set and error will be undefined
* if the job is not found, the result will be undefined and error will be set
*/
public async jobResult(data: {
jobId: string;
includeLogs?: boolean;
}): Promise<
| {
success: false;
error?: string;
result?: string;
}
| {
success: true;
error?: string;
result: JobData;
}
> {
const result = await this.apiHub("jobResult", data);
if (this.isError(result.data))
return {
success: false,
error: result.error,
result: result.data,
};
else
return {
success: result.success,
error: result.error,
result: result.success ? (result.data as JobData) : result.data,
};
}
/**
* Deploys the code to the cloud using serverless api call
* @param data the data for the deploy call
* @param data.repo the repo to use
* @param data.developer the developer
* @param data.packageManager the package manager to use
* @returns { success: boolean, error?: string, jobId?: string}
* where jobId is the jobId of the job
*/
public async deploy(data: {
repo: string;
developer: string;
packageManager: string;
}): Promise<{
success: boolean;
error?: string;
jobId?: string;
}> {
// TODO: encrypt env.json
const { repo, developer, packageManager } = data;
const result = await this.apiHub("deploy", {
developer,
repo,
args: packageManager,
});
if (
result.data === "error" ||
(typeof result.data === "string" && result.data.startsWith("error"))
)
return {
success: false,
error: result.error,
};
else
return {
success: result.success && result.data?.success,
jobId: result.data?.jobId,
error: result.error,
};
}
/**
* Gets the billing report for the jobs sent using JWT
* @returns { success: boolean, error?: string, result?: any }
* where result is the billing report
*/
public async queryBilling(): Promise<{
success: boolean;
error?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result?: any;
}> {
const result = await this.apiHub("queryBilling", {});
if (this.isError(result.data))
return {
success: false,
error: result.error,
result: result.data,
};
else
return {
success: result.success,
error: result.error,
result: result.data,
};
}
/**
* Gets the remaining balance
* @returns { success: boolean, error?: string, result?: any }
* where result is the balance
*/
public async getBalance(): Promise<{
success: boolean;
error?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result?: any;
}> {
const result = await this.apiHub("getBalance", {});
if (this.isError(result.data))
return {
success: false,
error: result.error,
result: result.data,
};
else
return {
success: result.success,
error: result.error,
result: result.data,
};
}
/**
* Waits for the job to finish
* @param data the data for the waitForJobResult call
* @param data.jobId the jobId of the job
* @param data.maxAttempts the maximum number of attempts, default is 360 (2 hours)
* @param data.interval the interval between attempts, default is 20000 (20 seconds)
* @param data.maxErrors the maximum number of network errors, default is 10
* @param data.printLogs print logs, default is true
* @returns { success: boolean, error?: string, result?: any }
* where result is the result of the job
*/
public async waitForJobResult(data: {
jobId: string;
maxAttempts?: number;
interval?: number;
maxErrors?: number;
printLogs?: boolean; // print logs, default is true
}): Promise<{
success: boolean;
error?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result?: any;
}> {
if (this.jwt === "local") return this.jobResult({ jobId: data.jobId });
const maxAttempts = data?.maxAttempts ?? 360; // 1 hour
const interval = data?.interval ?? 10000;
const maxErrors = data?.maxErrors ?? 10;
const errorDelay = 30000; // 30 seconds
const printedLogs: string[] = [];
const printLogs: boolean = data.printLogs ?? true;
function print(logs: string[]) {
logs.forEach((log) => {
if (printedLogs.includes(log) === false) {
printedLogs.push(log);
if (printLogs) {
// replace all occurrences of "error" with red color
const text = log.replace(/error/gi, (matched) =>
chalk.red(matched)
);
console.log(text);
}
}
});
}
let attempts = 0;
let errors = 0;
while (attempts < maxAttempts) {
const result = await this.apiHub("jobResult", {
jobId: data.jobId,
includeLogs: printLogs,
});
const isAllLogsFetched =
result?.data?.isFullLog === true || printLogs === false;
if (
printLogs === true &&
result?.data?.logs !== undefined &&
result?.data?.logs !== null &&
Array.isArray(result.data.logs) === true
)
print(result.data.logs);
if (result.success === false) {
errors++;
if (errors > maxErrors) {
return {
success: false,
error: "Too many network errors",
result: undefined,
};
}
await sleep(errorDelay * errors);
} else {
if (this.isError(result.data) && isAllLogsFetched)
return {
success: false,
error: result.error,
result: result.data,
};
else if (result.data?.result !== undefined && isAllLogsFetched) {
return {
success: result.success,
error: result.error,
result: result.data,
};
} else if (result.data?.jobStatus === "failed" && isAllLogsFetched) {
return {
success: false,
error: "Job failed",
result: result.data,
};
}
await sleep(interval);
}
attempts++;
}
return {
success: false,
error: "Timeout",
result: undefined,
};
}
/**
* Calls the serverless API
* @param command the command of the API
* @param data the data of the API
* */
private async apiHub(
command: ApiCommand,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<{ success: boolean; data?: any; error?: any }> {
if (this.jwt === "local") {
if (this.localWorker === undefined)
throw new Error("localWorker is undefined");
switch (command) {
case "recursiveProof": {
const jobId = await LocalCloud.run({
command: "recursiveProof",
data,
chain: this.chain,
localWorker: this.localWorker,
});
return {
success: true,
data: { success: true, jobId },
};
}
case "execute": {
const jobId = await LocalCloud.run({
command: "execute",
data,
chain: this.chain,
localWorker: this.localWorker,
});
if (data.mode === "sync")
return {
success: true,
data: LocalStorage.jobEvents[jobId].result,
};
else
return {
success: true,
data: { success: true, jobId },
};
}
case "jobResult": {
const job = LocalStorage.jobs[data.jobId];
if (job === undefined) {
return {
success: false,
error: "local job not found",
};
} else {
return {
success: true,
data: job,
};
}
}
case "sendTransactions": {
return {
success: true,
data: await LocalCloud.addTransactions(data.transactions),
};
}
case "deploy":
return {
success: true,
data: "local_deploy",
};
case "queryBilling":
return {
success: true,
data: "local_queryBilling",
};
default:
return {
success: false,
error: "local_error",
};
}
} else {
if (this.endpoint === undefined)
throw new Error(
"zkCloudWorker supports only mainnet, devnet and zeko chains in the cloud."
);
const apiData = {
auth: ZKCLOUDWORKER_AUTH,
command: command,
jwtToken: this.jwt,
data: data,
chain: this.chain,
webhook: this.webhook, // TODO: implement webhook code on AWS
};
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(apiData),
});
if (!response.ok) {
return {
success: false,
error: `fetch error: ${response.statusText} status: ${response.status}`,
};
}
const data = await response.json();
return { success: true, data: data };
} catch (error: any) {
console.error("apiHub error:", error.message ?? error);
return { success: false, error: error };
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private isError(data: any): boolean {
if (data === "error") return true;
if (data?.jobStatus === "failed") return true;
if (typeof data === "string" && data.toLowerCase().startsWith("error"))
return true;
if (data !== undefined && data.error !== undefined) return true;
return false;
}
}