sindri
Version:
The Sindri Labs JavaScript SDK and CLI tool.
716 lines (674 loc) • 28.3 kB
text/typescript
import { readFile, stat } from "fs/promises";
import path from "path";
import type { Readable } from "stream";
import gzip from "gzip-js";
import walk from "ignore-walk";
import type { WrapOptions as RetryOptions } from "retry";
import tar from "tar";
import Tar from "tar-js";
import { ApiClient, CircuitType, JobStatus, OpenAPIConfig } from "lib/api";
import type {
BoojumCircuitInfoResponse,
CircomCircuitInfoResponse,
CircuitStatusResponse,
GnarkCircuitInfoResponse,
HermezCircuitInfoResponse,
Halo2CircuitInfoResponse,
JoltCircuitInfoResponse,
NoirCircuitInfoResponse,
OpenvmCircuitInfoResponse,
Plonky2CircuitInfoResponse,
ProofInfoResponse,
ProofStatusResponse,
SnarkvmCircuitInfoResponse,
Sp1CircuitInfoResponse,
} from "lib/api";
import { Config } from "lib/config";
import { createLogger, type Logger, type LogLevel } from "lib/logging";
import { File, FormData } from "lib/isomorphic";
import type {
BrowserFile,
BrowserFormData,
NodeFile,
NodeFormData,
} from "lib/isomorphic";
import { type Meta, validateMetaAndMergeWithDefaults } from "lib/utils";
// Re-export types from the API.
export type {
BoojumCircuitInfoResponse,
CircomCircuitInfoResponse,
CircuitType,
GnarkCircuitInfoResponse,
Halo2CircuitInfoResponse,
HermezCircuitInfoResponse,
JoltCircuitInfoResponse,
JobStatus,
NoirCircuitInfoResponse,
OpenvmCircuitInfoResponse,
Plonky2CircuitInfoResponse,
ProofInfoResponse,
SnarkvmCircuitInfoResponse,
Sp1CircuitInfoResponse,
};
export type CircuitInfoResponse =
| BoojumCircuitInfoResponse
| CircomCircuitInfoResponse
| Halo2CircuitInfoResponse
| HermezCircuitInfoResponse
| JoltCircuitInfoResponse
| GnarkCircuitInfoResponse
| NoirCircuitInfoResponse
| OpenvmCircuitInfoResponse
| Plonky2CircuitInfoResponse
| SnarkvmCircuitInfoResponse
| Sp1CircuitInfoResponse;
// Re-export other internal types.
export type { Logger, LogLevel, Meta, RetryOptions };
/**
* The options for authenticating with the API.
*/
export interface AuthOptions {
/**
* The API key to use for authentication.
*/
apiKey?: string;
/**
* The base URL for the API.
*/
baseUrl?: string;
}
/**
* Represents the primary client for interacting with the Sindri ZKP service API. This class serves
* as the central entry point for the SDK, facilitating various operations such as compiling ZKP
* circuits and generating proofs.
*
* The {@link SindriClient} class encapsulates all the necessary methods and properties required to
* communicate effectively with the Sindri ZKP service, handling tasks like authentication, request
* management, and response processing.
*
* Usage of this class typically involves instantiating it with appropriate authentication options
* and then utilizing its methods to interact with the service.
*
* @example
* // Create an instance of the `SindriClient` class.
* const client = new SindriClient({ apiKey: 'your-api-key' });
*
* // Use the client to interact with the Sindri ZKP service...
*/
export class SindriClient {
/** @hidden */
readonly _client: ApiClient;
/** @hidden */
readonly _clientConfig: OpenAPIConfig;
/** @hidden */
readonly _config: Config | undefined;
readonly logger: Logger;
/**
* Represents the polling interval in milliseconds used for querying the status of an endpoint.
* This value determines the frequency at which the SDK polls an endpoint to check for any changes
* in status.
*
* The choice of polling interval is critical for balancing responsiveness against resource
* consumption. A shorter interval leads to more frequent updates, beneficial for
* rapidly-changing statuses, but at the expense of higher network and computational load. In
* contrast, a longer interval reduces resource usage but may delay the detection of status
* changes.
*
* For more complex ZKP circuits, which may take longer to compile, considering a larger polling
* interval could be advantageous. This approach minimizes unnecessary network traffic and
* computational effort while awaiting the completion of these time-intensive operations.
*
* The default value is set to 1000 milliseconds (1 second), offering a general balance. However,
* it can and should be adjusted based on the expected complexity and compilation time of the
* circuits being processed.
*/
public pollingInterval: number = 1000;
/**
* Represents the options for retrying requests to the Sindri ZKP service.
*
* See the [`retry` package](https://www.npmjs.com/package/retry#retrytimeoutsoptions)
* documentation for more information on the available options. The values here are the defaults,
* but they can be replaced with custom values in the constructor.
*/
public retryOptions: RetryOptions = {
minTimeout: 1000,
retries: 6,
};
/**
* Constructs a new instance of the {@link SindriClient} class for interacting with the Sindri ZKP
* service. This constructor initializes the client with the necessary authentication options.
*
* The provided `authOptions` parameter allows for specifying authentication credentials and
* configurations required for the client to communicate securely with the service. See
* {@link SindriClient.authorize} for more details about how authentication credentials are sourced.
*
* @param authOptions - The authentication options for the client, including
* credentials like API keys or tokens. Defaults to an empty object if not provided.
*
* @example
* // Instantiating the SindriClient with authentication options
* const client = new SindriClient({ apiKey: 'sindri-...-fskd' });
*
* @see {@link SindriClient.authorize} for information on retrieving this value.
*/
constructor(
authOptions: AuthOptions = {},
{ retryOptions }: { retryOptions?: RetryOptions } = {},
) {
// Initialize the client and store a reference to its config.
this._client = new ApiClient();
this._clientConfig = this._client.request.config;
// Set the `Sindri-Client` header.
const versionTag = process.env.VERSION
? `v${process.env.VERSION}`
: "unknown";
this._clientConfig.HEADERS = {
...this._clientConfig.HEADERS,
"Sindri-Client": `sindri-js-sdk/${versionTag}`,
};
// Create a local logger instance.
this.logger = createLogger();
if (!process.env.BROWSER_BUILD) {
this._config = new Config(this.logger);
}
this._clientConfig.sindri = this;
// Authorize the client.
this.authorize(authOptions);
// Store the retry options.
if (retryOptions) {
this.retryOptions = structuredClone(retryOptions);
}
}
/**
* Retrieves the current value of the client's API key used for authenticating with the Sindri ZKP
* service. This property is crucial for ensuring secure communication with the API and is
* typically set during client initialization.
*
* If the API key is not set or is in an invalid format (not a string), this getter returns
* `null`. Proper management of the API key is essential for the security and proper functioning
* of the SDK.
*
* @returns The current API key if set and valid, otherwise `null`.
*
* @example
* const currentApiKey = client.apiKey;
* if (currentApiKey) {
* console.log('API Key is set.');
* } else {
* console.log('API Key is not set or is invalid.');
* }
*/
get apiKey(): string | null {
if (
this._clientConfig.TOKEN &&
typeof this._clientConfig.TOKEN !== "string"
) {
return null;
}
return this._clientConfig.TOKEN || null;
}
/**
* Retrieves the current base URL of the Sindri ZKP service that the client is configured to
* interact with. This URL forms the foundation of all API requests made by the client and is
* typically set during client initialization. Anyone other than employees at Sindri can typically
* ignore this and use the default value of `https://sindri.app`.
*
* @returns The current base URL of the Sindri ZKP service.
*
* @example
* console.log(`Current base URL: ${client.baseUrl}`);
*/
get baseUrl(): string {
return this._clientConfig.BASE;
}
/** Retrieves the current log level of the client. The log level determines the verbosity of logs
* produced by the client which can be crucial for debugging and monitoring the client's
* interactions with the Sindri ZKP service.
*
* @returns The current log level of the client.
*
* @example
* console.log(`Current log level: ${client.logLevel}`);
*/
get logLevel(): LogLevel {
// We don't specify any custom log levels, so we can narrow the type to exclude strings.
return this.logger.level as LogLevel;
}
/**
* Sets the client's log level. This level determines the verbosity of logs produced by the
* client, allowing for flexible control over the amount of information logged during operation.
*
* @param level - The new log level to set for the client.
*
* @example
* // Set log level to debug.
* client.logLevel = "debug";
*/
set logLevel(level: LogLevel) {
this.logger.level = level;
this.logger.debug(`Set log level to "${this.logger.level}".`);
}
/**
* Authorizes the client with the Sindri ZKP service using the provided authentication options.
* This method is called automatically after initializing a client, but you may call it again if
* you would like to change the credentials. The logic around how credentials is as follows:
*
* 1. Any explicitly specified options in `authOptions` are always used if provided.
* 2. The `SINDRI_API_KEY` and `SINDRI_BASE_URL` environment variables are checked next.
* 3. The settings in `sindri.conf.json` (produced by running `sindri login` on the command-line) will be checked after that.
* 4. Finally, the default value of `https://sindri.app` will be used for the base URL (this is
* typically what you want unless you're an employee at Sindri). The API key will remain unset and
* you will only be able to make requests that allow anonymous access.
*
*
* @param authOptions - The authentication details required to authorize the client.
* @returns True if authorization is successful, false otherwise.
*
* @example
* const authOptions = { apiKey: 'sindri-...-jskd' };
* const isAuthorized = client.authorize(authOptions);
* if (isAuthorized) {
* console.log('Client is fully authorized.');
* } else {
* console.log('Client is not authorized.');
* }
*/
authorize(authOptions: AuthOptions): boolean {
if (process.env.BROWSER_BUILD) {
this._clientConfig.BASE = authOptions.baseUrl || "https://sindri.app";
this._clientConfig.TOKEN = authOptions.apiKey;
} else {
this._config!.reload();
this._clientConfig.BASE =
authOptions.baseUrl ||
process.env.SINDRI_BASE_URL ||
this._config!.auth?.baseUrl ||
this._clientConfig.BASE ||
"https://sindri.app";
this._clientConfig.TOKEN =
authOptions.apiKey ||
process.env.SINDRI_API_KEY ||
this._config!.auth?.apiKey;
}
return !!(this._clientConfig.BASE && this._clientConfig.TOKEN);
}
/**
* Creates a new {@link SindriClient} client instance. The class itself is not exported, so use
* this method on the exported (or any other) client instance to create a new instance. The new
* instance can be configured and used completely independently from any other instances. For
* example it can use different credentials or a different log level.
*
* @param authOptions - The authentication options for the client, including
* credentials like API keys or tokens. Defaults to an empty object if not provided.
* @param options - Additional options for configuring the client.
* @param options.retryOptions - The options related to retrying a request.
*
* @example
* import sindri from 'sindri';
*
* // Equivalent to: const myClient = new SindriClient({ ... });
* const myClient = sindri.create({ apiKey: 'sindri-mykey-1234'});
*
* @returns The new client instance.
*/
create(
authOptions: AuthOptions | undefined,
options:
| {
retryOptions?: RetryOptions;
}
| undefined,
): SindriClient {
return new SindriClient(authOptions, options);
}
/**
* Asynchronously creates and deploys a new circuit, initiating its compilation process. This
* method is essential for submitting new versions of circuits to the Sindri ZKP service for
* compilation. Upon deployment, it continuously polls the service to track the compilation status
* until the process either completes successfully or fails.
*
* The method accepts two parameters: `project` and `tags`. The `project` parameter can be either
* a string representing the path to the project or an array of files (browser or Node.js file
* objects) constituting the circuit. The `tags` parameter is used to assign tags to the deployed
* circuit, facilitating versioning and identification. By default, the circuit is tagged as
* "latest".
*
* After successful deployment and compilation, the method returns a `CircuitInfoResponse` object,
* which includes details about the compiled circuit, such as its identifier and status.
*
* @param project - In Node.js, this can either be a path to the root
* directory of a Sindri project, the path to a gzipped tarball containing the project, or an
* array of `buffer.File` objects. In a web browser, it can only be an array of `File` objects.
* @param tags - The list of tags, or singular tag if a string is passed, that
* should be associated with the deployed circuit. Defaults to `["latest"]`. Specify an empty
* array to indicate that you don't care about the compilation outputs and just want to see if it
* the circuit will compile.
* @param meta - An object containing metadata to associate with the circuit build. This will be
* merged into any metadata specified in the `SINDRI_META` environment variable. This variable can
* be a JSON object (*e.g.* `{"key": "value"}`) or a colon-delimited set of assignments (*e.g.*
* `key1=value1:key2=value2`).
* @returns A promise which resolves to the details of the deployed circuit.
*
* @example
* // Deploy a circuit with a project identifier and default `latest` tag.
* const circuit = await client.createCircuit("/path/to/circuit-directory/");
* console.log("Did circuit compilation succeed?", circuit.status);
*
* @example
* // Deploy a circuit with files and custom tags.
* await client.createCircuit([file1, file2], ['v1.0', 'experimental']);
*/
async createCircuit(
project: string | Array<BrowserFile | NodeFile>,
tags: string | string[] | null = ["latest"],
meta: Meta = {},
): Promise<CircuitInfoResponse> {
const formData = new FormData();
// First, validate the tags and them to the form data.
tags = typeof tags === "string" ? [tags] : tags ?? [];
for (const tag of tags) {
if (!/^[-a-zA-Z0-9_.]+$/.test(tag)) {
throw new Error(
`"${tag}" is not a valid tag. Tags may only contain alphanumeric characters, ` +
"underscores, hyphens, and periods.",
);
}
formData.append("tags", tag);
}
if (tags.length === 0) {
formData.append("tags", "");
}
// Validate and add the metadata.
formData.append(
"meta",
JSON.stringify(validateMetaAndMergeWithDefaults(meta)),
);
// Handle `project` being a file or directory path.
if (typeof project === "string") {
if (process.env.BROWSER_BUILD) {
throw new Error(
"Specifying `project` as a path is not allowed in the browser build.",
);
}
let projectStats;
try {
projectStats = await stat(project);
} catch {
throw new Error(
`The "${project}" path does not exist or you do not have permission to access it.`,
);
}
// If `project` is a path, then it's a prepackaged tarball.
if (projectStats.isFile()) {
if (!/\.(zip|tar|tar\.gz|tgz)$/i.test(project)) {
throw new Error("Only gzipped tarballs or zip files are supported.");
}
const tarballFilename = path.basename(project);
const tarballContent = await readFile(project);
(formData as NodeFormData).append(
"files",
new File([tarballContent], tarballFilename),
);
// If `project` is a directory, then we need to bundle it.
} else if (projectStats.isDirectory()) {
const sindriJsonPath = path.join(project, "sindri.json");
let sindriJsonContent;
try {
sindriJsonContent = await readFile(sindriJsonPath, {
encoding: "utf-8",
});
} catch {
throw new Error(
`Expected Sindri manifest file at "${sindriJsonPath}" does not exist.`,
);
}
let sindriJson;
try {
sindriJson = JSON.parse(sindriJsonContent) as { name: string };
} catch {
throw new Error(
`Could not parse "${sindriJsonPath}", is it valid JSON?`,
);
}
const circuitName = sindriJson?.name;
if (!circuitName) {
throw new Error(
`No circuit "name" field was found in "${sindriJsonPath}", the manifest is invalid.`,
);
}
// Create a tarball with all the files that should be included from the project.
const files = walk
.sync({
follow: true,
ignoreFiles: [".sindriignore"],
path: project,
})
.filter(
(file) =>
// Always exclude `.git` subdirectories.
!/(^|\/)\.git(\/|$)/.test(file),
);
// Always include the `sindri.json` file.
const sindriJsonFilename = path.basename(sindriJsonPath);
if (!files.includes(sindriJsonFilename)) {
files.push(sindriJsonFilename);
}
const tarballFilename = `${circuitName}.tar.gz`;
files.sort((a, b) => a.localeCompare(b)); // Deterministic for tests.
const tarStream = tar.c(
{
cwd: project,
gzip: true,
onwarn: (code: string, message: string) => {
this.logger.warn(`While creating tarball: ${code} - ${message}`);
},
prefix: `${circuitName}/`,
sync: true,
},
files,
// This works around a bug in the typing of `tar` when using `sync`.
) as unknown as Readable;
// Add the tarball to the form data.
(formData as NodeFormData).append(
"files",
new File([tarStream.read()], tarballFilename),
);
} else {
throw new Error(`The "${project}" path is not a file or directory.`);
}
// Handle an array of files.
} else if (Array.isArray(project)) {
// Validate the file array.
if (!project.every((file) => file instanceof File)) {
throw new Error("All entries in `project` must be `File` instances.");
}
const sindriJsonFile = project.find(
(file) => file.name === "sindri.json",
);
if (!sindriJsonFile) {
throw new Error(
"The `project` array must include a `sindri.json` file.",
);
}
let sindriJson;
try {
sindriJson = JSON.parse(await sindriJsonFile.text()) as {
name: string;
};
} catch {
throw new Error(`Could not parse "sindri.json", is it valid JSON?`);
}
const circuitName = sindriJson?.name;
if (!circuitName) {
throw new Error(
`No circuit "name" field was found in "sindri.json", the manifest is invalid.`,
);
}
// Create the gzipped tarball.
const tarball = new Tar();
project.sort((a, b) => a.name.localeCompare(b.name)); // Deterministic for tests.
for (const file of project) {
const content = new Uint8Array(await file.arrayBuffer());
await new Promise((resolve) =>
tarball.append(`${circuitName}/${file.name}`, content, resolve),
);
}
const gzippedTarball = new Uint8Array(gzip.zip(tarball.out));
const tarFile = new File([gzippedTarball], `${circuitName}.tar.gz`);
// Append the tarball to the form data.
// These lines are functionally identical, but we want to typecheck node and browser.
if (process.env.BROWSER_BUILD) {
(formData as BrowserFormData).append("files", tarFile as BrowserFile);
} else {
(formData as NodeFormData).append("files", tarFile as NodeFile);
}
}
const createResponse = await this._client.circuits.circuitCreate(
formData as NodeFormData,
);
const circuitId = createResponse.circuit_id;
while (true) {
const response: CircuitStatusResponse =
await this._client.internal.circuitStatus(circuitId);
if (response.status === "Ready" || response.status === "Failed") {
break;
}
await new Promise((resolve) => setTimeout(resolve, this.pollingInterval));
}
return this._client.circuits.circuitDetail(circuitId, false);
}
/**
* Retrieves all proofs associated with a specified circuit. This method is essential for
* obtaining a comprehensive list of proofs generated for a given circuit, identified by its
* unique circuit ID. It returns an array of `ProofInfoResponse` objects, each representing a
* proof associated with the circuit.
*
* The method is particularly useful in scenarios where tracking or auditing all proofs of a
* circuit is necessary. This could include verifying the integrity of proofs, understanding their
* usage, or simply enumerating them for record-keeping.
*
* The `circuitId` parameter is a string that uniquely identifies the circuit in question. It's
* crucial to provide the correct circuit ID to retrieve the corresponding proofs accurately.
*
* @param circuitId - The unique identifier of the circuit for which proofs are to be retrieved.
* @returns A promise that resolves to an array of details for each associated proof.
*
* @example
* const proofs = await client.getAllCircuitProofs(circuitId);
* console.log("Proofs:', proofs);
*/
async getAllCircuitProofs(circuitId: string): Promise<ProofInfoResponse[]> {
return await this._client.circuits.circuitProofs(circuitId);
}
/**
* Retrieves all circuits associated with the team. This method fetches a list of all circuits
* that have been created or accessed by the currently authenticated team. It's a key method for
* managing and monitoring circuit usage within a team, offering insights into the variety and
* scope of circuits in use.
*
* @returns A promise that resolves to an array of circuit information responses.
*
* @example
* const circuits = await = client.getAllCircuits();
* console.log("Circuits:", circuits);
*/
async getAllCircuits(): Promise<CircuitInfoResponse[]> {
return await this._client.circuits.circuitList();
}
/**
* Retrieves a specific circuit using its unique circuit ID. This method is crucial for obtaining
* detailed information about a particular circuit, identified by the provided `circuitId`. It's
* especially useful when detailed insights or operations on a single circuit are required, rather
* than handling multiple circuits.
*
* *Note:* In case the provided `circuitId` is invalid or does not correspond to an existing circuit,
* the promise may reject, indicating an error. Proper error handling is therefore essential when using this method.
*
* @param circuitId - The unique identifier of the circuit to retrieve.
* @returns A promise that resolves to the information about the specified circuit.
*
* @example
* const circuit = await client.getCircuit(circuitId);
* console.log('Circuit details:', circuit);
*/
async getCircuit(circuitId: string): Promise<CircuitInfoResponse> {
return await this._client.circuits.circuitDetail(circuitId);
}
/**
* Retrieves detailed information about a specific proof, identified by its unique proof ID. This
* method is vital for obtaining individual proof details, facilitating in-depth analysis or
* verification of a particular proof within the system.
*
* The `proofId` parameter is the key identifier for the proof, and it should be provided to fetch
* the corresponding information. The method returns a promise that resolves to a
* {@link ProofInfoResponse}, containing all relevant details of the proof.
*
* @param proofId - The unique identifier of the proof to retrieve.
* @returns A promise that resolves to the data about the specified proof.
*
* @example
* const proof = await client.getProof(proofId);
* console.log("Proof details:", proof);
*/
async getProof(proofId: string): Promise<ProofInfoResponse> {
return await this._client.proofs.proofDetail(proofId);
}
/**
* Generates a proof for a specified circuit. This method is critical for creating a new proof
* based on a given circuit, identified by `circuitId`, and the provided `proofInput`. It's
* primarily used to validate or verify certain conditions or properties of the circuit without
* revealing underlying data or specifics. The method continuously polls the service to track the
* compilation status until the process either completes successfully or fails.
*
* The `circuitId` parameter specifies the unique identifier of the circuit for which the proof is
* to be generated. The `proofInput` is a string that represents the necessary input data or
* parameters required for generating the proof.
*
* @param circuitId - The unique identifier of the circuit for which the proof is being generated.
* @param proofInput - The input data required for generating the proof. This should be a string
* containing either JSON data or TOML data (in the case of Noir).
* @param verify - A boolean indicating whether to perform a verification check of the generated
* proof.
* @param includeSmartContractCalldata - A boolean indicating whether to include calldata for the
* proof that can be passed into a smart contract for verification. Note that not all frameworks
* support this.
* @param meta - An object containing metadata to associate with the proof. This will be merged
* into any metadata specified in the `SINDRI_META` environment variable. This variable can be a
* JSON object (*e.g.* `{"key": "value"}`) or a colon-delimited set of assignments (*e.g.*
* `key1=value1:key2=value2`).
* @returns A promise that resolves to the information of the generated proof.
*
* @example
* const proof = await client.proveCircuit(circuitId, '{"X": 23, "Y": 52}');
* console.log("Generated proof:", proof);
*/
async proveCircuit(
circuitId: string,
proofInput: string,
verify: boolean = false,
includeSmartContractCalldata: boolean = false,
meta: Meta = {},
): Promise<ProofInfoResponse> {
const createResponse = await this._client.circuits.proofCreate(circuitId, {
meta: validateMetaAndMergeWithDefaults(meta), // This will raise an error if it's invalid.
perform_verify: verify,
proof_input: proofInput,
});
const proofId: string = createResponse.proof_id;
while (true) {
const response: ProofStatusResponse =
await this._client.internal.proofStatus(proofId);
if (response.status === "Ready" || response.status === "Failed") {
break;
}
await new Promise((resolve) => setTimeout(resolve, this.pollingInterval));
}
return this._client.proofs.proofDetail(
proofId,
true, // includeProof
true, // includePublic
includeSmartContractCalldata, // includeSmartContractCalldata
true, // includeVerificationKey
);
}
}