@stagtion/backend
Version:
MPC Phase 2 backend for Firebase services management
979 lines (969 loc) • 156 kB
JavaScript
/**
* @module @p0tion/backend
* @version 1.2.6
* @file MPC Phase 2 backend for Firebase services management
* @copyright Ethereum Foundation 2022
* @license MIT
* @see [Github]{@link https://github.com/privacy-scaling-explorations/p0tion}
*/
'use strict';
var admin = require('firebase-admin');
var functions = require('firebase-functions');
var dotenv = require('dotenv');
var actions = require('@stagtion/actions');
var htmlEntities = require('html-entities');
var firestore = require('firebase-admin/firestore');
var clientS3 = require('@aws-sdk/client-s3');
var s3RequestPresigner = require('@aws-sdk/s3-request-presigner');
var node_fs = require('node:fs');
var node_stream = require('node:stream');
var node_util = require('node:util');
var fs = require('fs');
var mime = require('mime-types');
var promises = require('timers/promises');
var fetch = require('@adobe/node-fetch-retry');
var path = require('path');
var os = require('os');
var clientSsm = require('@aws-sdk/client-ssm');
var clientEc2 = require('@aws-sdk/client-ec2');
var ethers = require('ethers');
var functionsV1 = require('firebase-functions/v1');
var functionsV2 = require('firebase-functions/v2');
var timerNode = require('timer-node');
var snarkjs = require('snarkjs');
var apiSdk = require('@bandada/api-sdk');
var auth = require('firebase-admin/auth');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var functions__namespace = /*#__PURE__*/_interopNamespaceDefault(functions);
var functionsV1__namespace = /*#__PURE__*/_interopNamespaceDefault(functionsV1);
var functionsV2__namespace = /*#__PURE__*/_interopNamespaceDefault(functionsV2);
/**
* Log levels.
* @notice useful to discriminate the log level for message printing.
* @enum {string}
*/
var LogLevel;
(function (LogLevel) {
LogLevel["INFO"] = "INFO";
LogLevel["DEBUG"] = "DEBUG";
LogLevel["WARN"] = "WARN";
LogLevel["ERROR"] = "ERROR";
LogLevel["LOG"] = "LOG";
})(LogLevel || (LogLevel = {}));
/**
* Create a new custom HTTPs error for cloud functions.
* @notice the set of Firebase Functions status codes. The codes are the same at the
* ones exposed by {@link https://github.com/grpc/grpc/blob/master/doc/statuscodes.md | gRPC}.
* @param errorCode <FunctionsErrorCode> - the set of possible error codes.
* @param message <string> - the error message.
* @param [details] <string> - the details of the error (optional).
* @returns <HttpsError>
*/
const makeError = (errorCode, message, details) => new functions__namespace.https.HttpsError(errorCode, message, details);
/**
* Log a custom message on console using a specific level.
* @param message <string> - the message to be shown.
* @param logLevel <LogLevel> - the level of the log to be used to show the message (e.g., debug, error).
*/
const printLog = (message, logLevel) => {
switch (logLevel) {
case LogLevel.INFO:
functions__namespace.logger.info(`[${logLevel}] ${message}`);
break;
case LogLevel.DEBUG:
functions__namespace.logger.debug(`[${logLevel}] ${message}`);
break;
case LogLevel.WARN:
functions__namespace.logger.warn(`[${logLevel}] ${message}`);
break;
case LogLevel.ERROR:
functions__namespace.logger.error(`[${logLevel}] ${message}`);
break;
case LogLevel.LOG:
functions__namespace.logger.log(`[${logLevel}] ${message}`);
break;
default:
console.log(`[${logLevel}] ${message}`);
break;
}
};
/**
* Log and throw an HTTPs error.
* @param error <HttpsError> - the error to be logged and thrown.
*/
const logAndThrowError = (error) => {
printLog(`${error.code}: ${error.message} ${!error.details ? "" : `\ndetails: ${error.details}`}`, LogLevel.ERROR);
throw error;
};
/**
* A set of Cloud Function specific errors.
* @notice these are errors that happen only on specific cloud functions.
*/
const SPECIFIC_ERRORS = {
SE_AUTH_NO_CURRENT_AUTH_USER: makeError("failed-precondition", "Unable to retrieve the authenticated user.", "Authenticated user information could not be retrieved. No document will be created in the relevant collection."),
SE_AUTH_SET_CUSTOM_USER_CLAIMS_FAIL: makeError("invalid-argument", "Unable to set custom claims for authenticated user."),
SE_AUTH_USER_NOT_REPUTABLE: makeError("permission-denied", "The authenticated user is not reputable.", "The authenticated user is not reputable. No document will be created in the relevant collection."),
SE_STORAGE_INVALID_BUCKET_NAME: makeError("already-exists", "Unable to create the AWS S3 bucket for the ceremony since the provided name is already in use. Please, provide a different bucket name for the ceremony.", "More info about the error could be found at the following link https://docs.aws.amazon.com/simspaceweaver/latest/userguide/troubleshooting_bucket-name-too-long.html"),
SE_STORAGE_TOO_MANY_BUCKETS: makeError("resource-exhausted", "Unable to create the AWS S3 bucket for the ceremony since the are too many buckets already in use. Please, delete 2 or more existing Amazon S3 buckets that you don't need or increase your limits.", "More info about the error could be found at the following link https://docs.aws.amazon.com/simspaceweaver/latest/userguide/troubeshooting_too-many-buckets.html"),
SE_STORAGE_MISSING_PERMISSIONS: makeError("permission-denied", "You do not have privileges to perform this operation.", "Authenticated user does not have proper permissions on AWS S3."),
SE_STORAGE_BUCKET_NOT_CONNECTED_TO_CEREMONY: makeError("not-found", "Unable to generate a pre-signed url for the given object in the provided bucket.", "The bucket is not associated with any valid ceremony document on the Firestore database."),
SE_STORAGE_WRONG_OBJECT_KEY: makeError("failed-precondition", "Unable to interact with a multi-part upload (start, create pre-signed urls or complete).", "The object key provided does not match the expected one."),
SE_STORAGE_CANNOT_INTERACT_WITH_MULTI_PART_UPLOAD: makeError("failed-precondition", "Unable to interact with a multi-part upload (start, create pre-signed urls or complete).", "Authenticated user is not a current contributor which is currently in the uploading step."),
SE_STORAGE_DOWNLOAD_FAILED: makeError("failed-precondition", "Unable to download the AWS S3 object from the provided ceremony bucket.", "This could happen if the file reference stored in the database or bucket turns out to be wrong or if the pre-signed url was not generated correctly."),
SE_STORAGE_UPLOAD_FAILED: makeError("failed-precondition", "Unable to upload the file to the AWS S3 ceremony bucket.", "This could happen if the local file or bucket do not exist or if the pre-signed url was not generated correctly."),
SE_STORAGE_DELETE_FAILED: makeError("failed-precondition", "Unable to delete the AWS S3 object from the provided ceremony bucket.", "This could happen if the local file or the bucket do not exist."),
SE_CONTRIBUTE_NO_CEREMONY_CIRCUITS: makeError("not-found", "There is no circuit associated with the ceremony.", "No documents in the circuits subcollection were found for the selected ceremony."),
SE_CONTRIBUTE_NO_OPENED_CEREMONIES: makeError("not-found", "There are no ceremonies open to contributions."),
SE_CONTRIBUTE_CANNOT_PROGRESS_TO_NEXT_CIRCUIT: makeError("failed-precondition", "Unable to progress to next circuit for contribution", "In order to progress for the contribution the participant must have just been registered for the ceremony or have just finished a contribution."),
SE_PARTICIPANT_CEREMONY_NOT_OPENED: makeError("failed-precondition", "Unable to progress to next contribution step.", "The ceremony does not appear to be opened"),
SE_PARTICIPANT_NOT_CONTRIBUTING: makeError("failed-precondition", "Unable to progress to next contribution step.", "This may happen due wrong contribution step from participant."),
SE_PARTICIPANT_CANNOT_STORE_PERMANENT_DATA: makeError("failed-precondition", "Unable to store contribution hash and computing time.", "This may happen due wrong contribution step from participant or missing coordinator permission (only when finalizing)."),
SE_PARTICIPANT_CANNOT_STORE_TEMPORARY_DATA: makeError("failed-precondition", "Unable to store temporary data to resume a multi-part upload.", "This may happen due wrong contribution step from participant."),
SE_VERIFICATION_NO_PARTICIPANT_CONTRIBUTION_DATA: makeError("not-found", `Unable to retrieve current contribution data from participant document.`),
SE_CEREMONY_CANNOT_FINALIZE_CEREMONY: makeError("failed-precondition", `Unable to finalize the ceremony.`, `Please, verify to have successfully completed the finalization of each circuit in the ceremony.`),
SE_FINALIZE_NO_CEREMONY_CONTRIBUTIONS: makeError("not-found", "There are no contributions associated with the ceremony circuit.", "No documents in the contributions subcollection were found for the selected ceremony circuit."),
SE_FINALIZE_NO_FINAL_CONTRIBUTION: makeError("not-found", "There is no final contribution associated with the ceremony circuit."),
SE_VM_NOT_RUNNING: makeError("failed-precondition", "The EC2 VM is not running yet"),
SE_VM_FAILED_COMMAND_EXECUTION: makeError("failed-precondition", "VM command execution failed", "Please, contact the coordinator if this error persists."),
SE_VM_TIMEDOUT_COMMAND_EXECUTION: makeError("deadline-exceeded", "VM command execution took too long and has been timed-out", "Please, contact the coordinator if this error persists."),
SE_VM_CANCELLED_COMMAND_EXECUTION: makeError("cancelled", "VM command execution has been cancelled", "Please, contact the coordinator if this error persists."),
SE_VM_DELAYED_COMMAND_EXECUTION: makeError("unavailable", "VM command execution has been delayed since there were no available instance at the moment", "Please, contact the coordinator if this error persists."),
SE_VM_UNKNOWN_COMMAND_STATUS: makeError("unavailable", "VM command execution has failed due to an unknown status code", "Please, contact the coordinator if this error persists.")
};
/**
* A set of common errors.
* @notice these are errors that happen on multiple cloud functions (e.g., auth, missing data).
*/
const COMMON_ERRORS = {
CM_NOT_COORDINATOR_ROLE: makeError("permission-denied", "You do not have privileges to perform this operation.", "Authenticated user does not have the coordinator role (missing custom claims)."),
CM_MISSING_OR_WRONG_INPUT_DATA: makeError("invalid-argument", "Unable to perform the operation due to incomplete or incorrect data."),
CM_WRONG_CONFIGURATION: makeError("failed-precondition", "Missing or incorrect configuration.", "This may happen due wrong environment configuration for the backend services."),
CM_NOT_AUTHENTICATED: makeError("failed-precondition", "You are not authorized to perform this operation.", "You could not perform the requested operation because you are not authenticated on the Firebase Application."),
CM_INEXISTENT_DOCUMENT: makeError("not-found", "Unable to find a document with the given identifier for the provided collection path."),
CM_INEXISTENT_DOCUMENT_DATA: makeError("not-found", "The provided document with the given identifier has no data associated with it.", "This problem may occur if the document has not yet been written in the database."),
CM_INVALID_CEREMONY_FOR_PARTICIPANT: makeError("not-found", "The participant does not seem to be related to a ceremony."),
CM_NO_CIRCUIT_FOR_GIVEN_SEQUENCE_POSITION: makeError("not-found", "Unable to find the circuit having the provided sequence position for the given ceremony"),
CM_INVALID_REQUEST: makeError("unknown", "Failed request."),
CM_INVALID_COMMAND_EXECUTION: makeError("unknown", "There was an error while executing the command on the VM", "Please, contact the coordinator if the error persists.")
};
dotenv.config();
let provider;
/**
* Return a configured and connected instance of the AWS S3 client.
* @dev this method check and utilize the environment variables to configure the connection
* w/ the S3 client.
* @returns <Promise<S3Client>> - the instance of the connected S3 Client instance.
*/
const getS3Client = async () => {
if (!process.env.AWS_ACCESS_KEY_ID ||
!process.env.AWS_SECRET_ACCESS_KEY ||
!process.env.AWS_REGION ||
!process.env.AWS_PRESIGNED_URL_EXPIRATION ||
!process.env.AWS_CEREMONY_BUCKET_POSTFIX)
logAndThrowError(COMMON_ERRORS.CM_WRONG_CONFIGURATION);
// Return the connected S3 Client instance.
return new clientS3.S3Client({
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
},
region: process.env.AWS_REGION
});
};
/**
* Returns a Prvider, connected via a configured JSON URL or else
* the ethers.js default provider, using configured API keys.
* @returns <ethers.providers.Provider> An Eth node provider
*/
const setEthProvider = () => {
if (provider)
return provider;
console.log(`setting new provider`);
// Use JSON URL if defined
// if ((hardhat as any).ethers) {
// console.log(`using hardhat.ethers provider`)
// provider = (hardhat as any).ethers.provider
// } else
if (process.env.ETH_PROVIDER_JSON_URL) {
console.log(`JSON URL provider at ${process.env.ETH_PROVIDER_JSON_URL}`);
provider = new ethers.providers.JsonRpcProvider({
url: process.env.ETH_PROVIDER_JSON_URL,
skipFetchSetup: true
});
}
else {
// Otherwise, connect the default provider with ALchemy, Infura, or both
provider = ethers.providers.getDefaultProvider("homestead", {
alchemy: process.env.ETH_PROVIDER_ALCHEMY_API_KEY,
infura: process.env.ETH_PROVIDER_INFURA_API_KEY
});
}
return provider;
};
dotenv.config();
/**
* Get a specific document from database.
* @dev this method differs from the one in the `actions` package because we need to use
* the admin SDK here; therefore the Firestore instances are not interchangeable between admin
* and user instance.
* @param collection <string> - the name of the collection.
* @param documentId <string> - the unique identifier of the document in the collection.
* @returns <Promise<DocumentSnapshot<DocumentData>>> - the requested document w/ relative data.
*/
const getDocumentById = async (collection, documentId) => {
// Prepare Firestore db instance.
const firestore = admin.firestore();
// Get document.
const doc = await firestore.collection(collection).doc(documentId).get();
// Return only if doc exists; otherwise throw error.
return doc.exists ? doc : logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT);
};
/**
* Get the current server timestamp.
* @dev the value is in milliseconds.
* @returns <number> - the timestamp of the server (ms).
*/
const getCurrentServerTimestampInMillis = () => firestore.Timestamp.now().toMillis();
/**
* Interrupt the current execution for a specified amount of time.
* @param ms <number> - the amount of time expressed in milliseconds.
*/
const sleep = async (ms) => promises.setTimeout(ms);
/**
* Query for ceremony circuits.
* @notice the order by sequence position is fundamental to maintain parallelism among contributions for different circuits.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @returns Promise<Array<FirebaseDocumentInfo>> - the ceremony' circuits documents ordered by sequence position.
*/
const getCeremonyCircuits = async (ceremonyId) => {
// Prepare Firestore db instance.
const firestore = admin.firestore();
// Execute query.
const querySnap = await firestore.collection(actions.getCircuitsCollectionPath(ceremonyId)).get();
if (!querySnap.docs)
logAndThrowError(SPECIFIC_ERRORS.SE_CONTRIBUTE_NO_CEREMONY_CIRCUITS);
return querySnap.docs.sort((a, b) => a.data().sequencePosition - b.data().sequencePosition);
};
/**
* Query for ceremony circuit contributions.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @param circuitId <string> - the unique identifier of the circuitId.
* @returns Promise<Array<FirebaseDocumentInfo>> - the contributions of the ceremony circuit.
*/
const getCeremonyCircuitContributions = async (ceremonyId, circuitId) => {
// Prepare Firestore db instance.
const firestore = admin.firestore();
// Execute query.
const querySnap = await firestore.collection(actions.getContributionsCollectionPath(ceremonyId, circuitId)).get();
if (!querySnap.docs)
logAndThrowError(SPECIFIC_ERRORS.SE_FINALIZE_NO_CEREMONY_CONTRIBUTIONS);
return querySnap.docs;
};
/**
* Query not expired timeouts.
* @notice a timeout is considered valid (aka not expired) if and only if the timeout end date
* value is less than current timestamp.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @param participantId <string> - the unique identifier of the participant.
* @returns <Promise<QuerySnapshot<DocumentData>>>
*/
const queryNotExpiredTimeouts = async (ceremonyId, participantId) => {
// Prepare Firestore db.
const firestoreDb = admin.firestore();
// Execute and return query result.
return firestoreDb
.collection(actions.getTimeoutsCollectionPath(ceremonyId, participantId))
.where(actions.commonTerms.collections.timeouts.fields.endDate, ">=", getCurrentServerTimestampInMillis())
.get();
};
/**
* Query for opened ceremonies.
* @param firestoreDatabase <Firestore> - the Firestore service instance associated to the current Firebase application.
* @returns <Promise<Array<FirebaseDocumentInfo>>>
*/
const queryOpenedCeremonies = async () => {
const querySnap = await admin
.firestore()
.collection(actions.commonTerms.collections.ceremonies.name)
.where(actions.commonTerms.collections.ceremonies.fields.state, "==", "OPENED" /* CeremonyState.OPENED */)
.where(actions.commonTerms.collections.ceremonies.fields.endDate, ">=", getCurrentServerTimestampInMillis())
.get();
if (!querySnap.docs)
logAndThrowError(SPECIFIC_ERRORS.SE_CONTRIBUTE_NO_OPENED_CEREMONIES);
return querySnap.docs;
};
/**
* Get ceremony circuit document by sequence position.
* @param ceremonyId <string> - the unique identifier of the ceremony.
* @param sequencePosition <number> - the sequence position of the circuit.
* @returns Promise<QueryDocumentSnapshot<DocumentData>>
*/
const getCircuitDocumentByPosition = async (ceremonyId, sequencePosition) => {
// Query for all ceremony circuits.
const circuits = await getCeremonyCircuits(ceremonyId);
// Apply a filter using the sequence position.
const matchedCircuits = circuits.filter((circuit) => circuit.data().sequencePosition === sequencePosition);
if (matchedCircuits.length !== 1)
logAndThrowError(COMMON_ERRORS.CM_NO_CIRCUIT_FOR_GIVEN_SEQUENCE_POSITION);
return matchedCircuits.at(0);
};
/**
* Create a temporary file path in the virtual memory of the cloud function.
* @dev useful when downloading files from AWS S3 buckets for processing within cloud functions.
* @param completeFilename <string> - the complete file name (name + ext).
* @returns <string> - the path to the local temporary location.
*/
const createTemporaryLocalPath = (completeFilename) => path.join(os.tmpdir(), completeFilename);
/**
* Download an artifact from the AWS S3 bucket.
* @dev this method uses streams.
* @param bucketName <string> - the name of the bucket.
* @param objectKey <string> - the unique key to identify the object inside the given AWS S3 bucket.
* @param localFilePath <string> - the local path where the file will be stored.
*/
const downloadArtifactFromS3Bucket = async (bucketName, objectKey, localFilePath) => {
// Prepare AWS S3 client instance.
const client = await getS3Client();
// Prepare command.
const command = new clientS3.GetObjectCommand({ Bucket: bucketName, Key: objectKey });
// Generate a pre-signed url for downloading the file.
const url = await s3RequestPresigner.getSignedUrl(client, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) });
// Execute download request.
// @ts-ignore
const response = await fetch(url, {
method: "GET",
headers: {
"Access-Control-Allow-Origin": "*"
}
});
if (response.status !== 200 || !response.ok)
logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_DOWNLOAD_FAILED);
// Write the file locally using streams.
const writeStream = node_fs.createWriteStream(localFilePath);
const streamPipeline = node_util.promisify(node_stream.pipeline);
await streamPipeline(response.body, writeStream);
writeStream.on("finish", () => {
writeStream.end();
});
};
/**
* Upload a new artifact to the AWS S3 bucket.
* @dev this method uses streams.
* @param bucketName <string> - the name of the bucket.
* @param objectKey <string> - the unique key to identify the object inside the given AWS S3 bucket.
* @param localFilePath <string> - the local path where the file to be uploaded is stored.
*/
const uploadFileToBucket = async (bucketName, objectKey, localFilePath, isPublic = false) => {
// Prepare AWS S3 client instance.
const client = await getS3Client();
// Extract content type.
const contentType = mime.lookup(localFilePath) || "";
// Prepare command.
const command = new clientS3.PutObjectCommand({
Bucket: bucketName,
Key: objectKey,
ContentType: contentType,
ACL: isPublic ? "public-read" : "private"
});
// Generate a pre-signed url for uploading the file.
const url = await s3RequestPresigner.getSignedUrl(client, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) });
// Execute upload request.
// @ts-ignore
const response = await fetch(url, {
method: "PUT",
body: fs.readFileSync(localFilePath),
headers: { "Content-Type": contentType }
});
if (response.status !== 200 || !response.ok)
logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_UPLOAD_FAILED);
};
const uploadFileToBucketNoFile = async (bucketName, objectKey, data, isPublic = false) => {
// Prepare AWS S3 client instance.
const client = await getS3Client();
// Prepare command.
const command = new clientS3.PutObjectCommand({
Bucket: bucketName,
Key: objectKey,
ContentType: "text/plain",
ACL: isPublic ? "public-read" : "private"
});
// Generate a pre-signed url for uploading the file.
const url = await s3RequestPresigner.getSignedUrl(client, command, { expiresIn: Number(process.env.AWS_PRESIGNED_URL_EXPIRATION) });
// Execute upload request.
// @ts-ignore
const response = await fetch(url, {
method: "PUT",
body: data,
headers: { "Content-Type": "text/plain" }
});
if (response.status !== 200 || !response.ok)
logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_UPLOAD_FAILED);
};
/**
* Upload an artifact from the AWS S3 bucket.
* @param bucketName <string> - the name of the bucket.
* @param objectKey <string> - the unique key to identify the object inside the given AWS S3 bucket.
*/
const deleteObject = async (bucketName, objectKey) => {
// Prepare AWS S3 client instance.
const client = await getS3Client();
// Prepare command.
const command = new clientS3.DeleteObjectCommand({ Bucket: bucketName, Key: objectKey });
// Execute command.
const data = await client.send(command);
if (data.$metadata.httpStatusCode !== 204)
logAndThrowError(SPECIFIC_ERRORS.SE_STORAGE_DELETE_FAILED);
};
/**
* Query ceremonies by state and (start/end) date value.
* @param state <string> - the state of the ceremony.
* @param needToCheckStartDate <boolean> - flag to discriminate when to check startDate (true) or endDate (false).
* @param check <WhereFilerOp> - the type of filter (query check - e.g., '<' or '>').
* @returns <Promise<admin.firestore.QuerySnapshot<admin.firestore.DocumentData>>> - the queried ceremonies after filtering operation.
*/
const queryCeremoniesByStateAndDate = async (state, needToCheckStartDate, check) => admin
.firestore()
.collection(actions.commonTerms.collections.ceremonies.name)
.where(actions.commonTerms.collections.ceremonies.fields.state, "==", state)
.where(needToCheckStartDate
? actions.commonTerms.collections.ceremonies.fields.startDate
: actions.commonTerms.collections.ceremonies.fields.endDate, check, getCurrentServerTimestampInMillis())
.get();
/**
* Return the document associated with the final contribution for a ceremony circuit.
* @dev this method is useful during ceremony finalization.
* @param ceremonyId <string> -
* @param circuitId <string> -
* @returns Promise<QueryDocumentSnapshot<DocumentData>> - the final contribution for the ceremony circuit.
*/
const getFinalContribution = async (ceremonyId, circuitId) => {
// Get contributions for the circuit.
const contributions = await getCeremonyCircuitContributions(ceremonyId, circuitId);
// Match the final one.
const matchContribution = contributions.filter((contribution) => contribution.data().zkeyIndex === actions.finalContributionIndex);
if (!matchContribution)
logAndThrowError(SPECIFIC_ERRORS.SE_FINALIZE_NO_FINAL_CONTRIBUTION);
// Get the final contribution.
// nb. there must be only one final contributions x circuit.
const finalContribution = matchContribution.at(0);
return finalContribution;
};
/**
* Helper function to HTML encode circuit data.
* @param circuitDocument <CircuitDocument> - the circuit document to be encoded.
* @returns <CircuitDocument> - the circuit document encoded.
*/
const htmlEncodeCircuitData = (circuitDocument) => ({
...circuitDocument,
description: htmlEntities.encode(circuitDocument.description),
name: htmlEntities.encode(circuitDocument.name),
prefix: htmlEntities.encode(circuitDocument.prefix)
});
/**
* Fetch the variables related to GitHub anti-sybil checks
* @returns <any> - the GitHub variables.
*/
const getGitHubVariables = () => {
if (!process.env.GITHUB_MINIMUM_FOLLOWERS ||
!process.env.GITHUB_MINIMUM_FOLLOWING ||
!process.env.GITHUB_MINIMUM_PUBLIC_REPOS ||
!process.env.GITHUB_MINIMUM_AGE)
logAndThrowError(COMMON_ERRORS.CM_WRONG_CONFIGURATION);
return {
minimumFollowers: Number(process.env.GITHUB_MINIMUM_FOLLOWERS),
minimumFollowing: Number(process.env.GITHUB_MINIMUM_FOLLOWING),
minimumPublicRepos: Number(process.env.GITHUB_MINIMUM_PUBLIC_REPOS),
minimumAge: Number(process.env.GITHUB_MINIMUM_AGE)
};
};
/**
* Fetch the variables related to EC2 verification
* @returns <any> - the AWS EC2 variables.
*/
const getAWSVariables = () => {
if (!process.env.AWS_ACCESS_KEY_ID ||
!process.env.AWS_SECRET_ACCESS_KEY ||
!process.env.AWS_INSTANCE_PROFILE_ARN ||
!process.env.AWS_AMI_ID ||
!process.env.AWS_SNS_TOPIC_ARN)
logAndThrowError(COMMON_ERRORS.CM_WRONG_CONFIGURATION);
return {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION || "eu-central-1",
instanceProfileArn: process.env.AWS_INSTANCE_PROFILE_ARN,
amiId: process.env.AWS_AMI_ID,
snsTopic: process.env.AWS_SNS_TOPIC_ARN
};
};
/**
* Create an EC2 client object
* @returns <Promise<EC2Client>> an EC2 client
*/
const createEC2Client = async () => {
const { accessKeyId, secretAccessKey, region } = getAWSVariables();
const ec2 = new clientEc2.EC2Client({
credentials: {
accessKeyId,
secretAccessKey
},
region
});
return ec2;
};
/**
* Create an SSM client object
* @returns <Promise<SSMClient>> an SSM client
*/
const createSSMClient = async () => {
const { accessKeyId, secretAccessKey, region } = getAWSVariables();
const ssm = new clientSsm.SSMClient({
credentials: {
accessKeyId,
secretAccessKey
},
region
});
return ssm;
};
dotenv.config();
/**
* Record the authenticated user information inside the Firestore DB upon authentication.
* @dev the data is recorded in a new document in the `users` collection.
* @notice this method is automatically triggered upon user authentication in the Firebase app
* which uses the Firebase Authentication service.
*/
const registerAuthUser = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.auth.user()
.onCreate(async (user) => {
// Get DB.
const firestore = admin.firestore();
// Get user information.
if (!user.uid)
logAndThrowError(SPECIFIC_ERRORS.SE_AUTH_NO_CURRENT_AUTH_USER);
// The user object has basic properties such as display name, email, etc.
const { displayName } = user;
const { email } = user;
const { photoURL } = user;
const { emailVerified } = user;
// Metadata.
const { creationTime } = user.metadata;
const { lastSignInTime } = user.metadata;
// The user's ID, unique to the Firebase project. Do NOT use
// this value to authenticate with your backend server, if
// you have one. Use User.getToken() instead.
const { uid } = user;
// Reference to a document using uid.
const userRef = firestore.collection(actions.commonTerms.collections.users.name).doc(uid);
// html encode the display name (or put the ID if the name is not displayed)
const encodedDisplayName = user.displayName === "Null" || user.displayName === null ? user.uid : htmlEntities.encode(displayName);
// store the avatar URL of a contributor
let avatarUrl = "";
// we only do reputation check if the user is not a coordinator
if (!(email?.endsWith(`@${process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN}`) ||
email === process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN)) {
const auth = admin.auth();
// if provider == github.com let's use our functions to check the user's reputation
if (user.providerData.length > 0 && user.providerData[0].providerId === "github.com") {
const vars = getGitHubVariables();
// this return true or false
try {
const { reputable, avatarUrl: avatarURL } = await actions.githubReputation(user.providerData[0].uid, vars.minimumFollowing, vars.minimumFollowers, vars.minimumPublicRepos, vars.minimumAge);
if (!reputable) {
// Delete user
await auth.deleteUser(user.uid);
// Throw error
logAndThrowError(makeError("permission-denied", "The user is not allowed to sign up because their Github reputation is not high enough.", `The user ${user.displayName === "Null" || user.displayName === null
? user.uid
: user.displayName} is not allowed to sign up because their Github reputation is not high enough. Please contact the administrator if you think this is a mistake.`));
}
// store locally
avatarUrl = avatarURL;
printLog(`Github reputation check passed for user ${user.displayName === "Null" || user.displayName === null ? user.uid : user.displayName}`, LogLevel.DEBUG);
}
catch (error) {
// Delete user
await auth.deleteUser(user.uid);
logAndThrowError(makeError("permission-denied", "There was an error while checking the user's Github reputation.", `${error}`));
}
}
}
// Set document (nb. we refer to providerData[0] because we use Github OAuth provider only).
// In future releases we might want to loop through the providerData array as we support
// more providers.
await userRef.set({
name: encodedDisplayName,
encodedDisplayName,
// Metadata.
creationTime,
lastSignInTime: lastSignInTime || creationTime,
// Optional.
email: email || "",
emailVerified: emailVerified || false,
photoURL: photoURL || "",
lastUpdated: getCurrentServerTimestampInMillis()
});
// we want to create a new collection for the users to store the avatars
const avatarRef = firestore.collection(actions.commonTerms.collections.avatars.name).doc(uid);
await avatarRef.set({
avatarUrl: avatarUrl || ""
});
printLog(`Authenticated user document with identifier ${uid} has been correctly stored`, LogLevel.DEBUG);
printLog(`Authenticated user avatar with identifier ${uid} has been correctly stored`, LogLevel.DEBUG);
});
/**
* Set custom claims for role-based access control on the newly created user.
* @notice this method is automatically triggered upon user authentication in the Firebase app
* which uses the Firebase Authentication service.
*/
const processSignUpWithCustomClaims = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.auth.user()
.onCreate(async (user) => {
// Get user information.
if (!user.uid)
logAndThrowError(SPECIFIC_ERRORS.SE_AUTH_NO_CURRENT_AUTH_USER);
// Prepare state.
let customClaims;
// Check if user meets role criteria to be a coordinator.
if (user.email &&
(user.email.endsWith(`@${process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN}`) ||
user.email === process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN)) {
customClaims = { coordinator: true };
printLog(`Authenticated user ${user.uid} has been identified as coordinator`, LogLevel.DEBUG);
}
else {
customClaims = { participant: true };
printLog(`Authenticated user ${user.uid} has been identified as participant`, LogLevel.DEBUG);
}
try {
// Set custom user claims on this newly created user.
await admin.auth().setCustomUserClaims(user.uid, customClaims);
}
catch (error) {
const specificError = SPECIFIC_ERRORS.SE_AUTH_SET_CUSTOM_USER_CLAIMS_FAIL;
const additionalDetails = error.toString();
logAndThrowError(makeError(specificError.code, specificError.message, additionalDetails));
}
});
dotenv.config();
/**
* Make a scheduled ceremony open.
* @dev this function automatically runs every 30 minutes.
* @todo this methodology for transitioning a ceremony from `scheduled` to `opened` state will be replaced with one
* that resolves the issues presented in the issue #192 (https://github.com/quadratic-funding/mpc-phase2-suite/issues/192).
*/
const startCeremony = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.pubsub.schedule(`every 30 minutes`)
.onRun(async () => {
// Get ready to be opened ceremonies.
const scheduledCeremoniesQuerySnap = await queryCeremoniesByStateAndDate("SCHEDULED" /* CeremonyState.SCHEDULED */, true, "<=");
if (!scheduledCeremoniesQuerySnap.empty)
scheduledCeremoniesQuerySnap.forEach(async (ceremonyDoc) => {
// Make state transition to start ceremony.
await ceremonyDoc.ref.set({ state: "OPENED" /* CeremonyState.OPENED */ }, { merge: true });
printLog(`Ceremony ${ceremonyDoc.id} is now open`, LogLevel.DEBUG);
});
});
/**
* Make a scheduled ceremony close.
* @dev this function automatically runs every 30 minutes.
* @todo this methodology for transitioning a ceremony from `opened` to `closed` state will be replaced with one
* that resolves the issues presented in the issue #192 (https://github.com/quadratic-funding/mpc-phase2-suite/issues/192).
*/
const stopCeremony = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.pubsub.schedule(`every 30 minutes`)
.onRun(async () => {
// Get opened ceremonies.
const runningCeremoniesQuerySnap = await queryCeremoniesByStateAndDate("OPENED" /* CeremonyState.OPENED */, false, "<=");
if (!runningCeremoniesQuerySnap.empty) {
runningCeremoniesQuerySnap.forEach(async (ceremonyDoc) => {
// Make state transition to close ceremony.
await ceremonyDoc.ref.set({ state: "CLOSED" /* CeremonyState.CLOSED */ }, { merge: true });
printLog(`Ceremony ${ceremonyDoc.id} is now closed`, LogLevel.DEBUG);
});
}
});
/**
* Register all ceremony setup-related documents on the Firestore database.
* @dev this function will create a new document in the `ceremonies` collection and as needed `circuit`
* documents in the sub-collection.
*/
const setupCeremony = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.https.onCall(async (data, context) => {
// Check if the user has the coordinator claim.
if (!context.auth || !context.auth.token.coordinator)
logAndThrowError(COMMON_ERRORS.CM_NOT_COORDINATOR_ROLE);
// Validate the provided data.
if (!data.ceremonyInputData || !data.ceremonyPrefix || !data.circuits.length)
logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA);
// Prepare Firestore DB.
const firestore = admin.firestore();
const batch = firestore.batch();
// Prepare data.
const { ceremonyInputData, ceremonyPrefix, circuits } = data;
const userId = context.auth?.uid;
// Create a new ceremony document.
const ceremonyDoc = await firestore.collection(`${actions.commonTerms.collections.ceremonies.name}`).doc().get();
// Prepare tx to write ceremony data.
batch.create(ceremonyDoc.ref, {
title: htmlEntities.encode(ceremonyInputData.title),
description: htmlEntities.encode(ceremonyInputData.description),
startDate: new Date(ceremonyInputData.startDate).valueOf(),
endDate: new Date(ceremonyInputData.endDate).valueOf(),
prefix: ceremonyPrefix,
state: "SCHEDULED" /* CeremonyState.SCHEDULED */,
type: "PHASE2" /* CeremonyType.PHASE2 */,
penalty: ceremonyInputData.penalty,
timeoutType: ceremonyInputData.timeoutMechanismType,
coordinatorId: userId,
lastUpdated: getCurrentServerTimestampInMillis()
});
// Get the bucket name so we can upload the startup script
const bucketName = actions.getBucketName(ceremonyPrefix, String(process.env.AWS_CEREMONY_BUCKET_POSTFIX));
// Create a new circuit document (circuits ceremony document sub-collection).
for (let circuit of circuits) {
// The VM unique identifier (if any).
let vmInstanceId = "";
// Get a new circuit document.
const ccp = actions.getCircuitsCollectionPath(ceremonyDoc.ref.id);
printLog(`CircuitsCollectionPath = ${ccp}`, LogLevel.DEBUG);
const circuitDoc = await firestore.collection(ccp).doc().get();
// Check if using the VM approach for contribution verification.
if (circuit.verification.cfOrVm === "VM" /* CircuitContributionVerificationMechanism.VM */) {
// VM command to be run at the startup.
const startupCommand = actions.vmBootstrapCommand(`${bucketName}/circuits/${circuit.name}`);
// Get EC2 client.
const ec2Client = await createEC2Client();
// Get AWS variables.
const { snsTopic, region } = getAWSVariables();
// Prepare dependencies and cache artifacts command.
const vmCommands = actions.vmDependenciesAndCacheArtifactsCommand(`${bucketName}/${circuit.files?.initialZkeyStoragePath}`, `${bucketName}/${circuit.files?.potStoragePath}`, snsTopic, region);
printLog(`Check VM dependencies and cache artifacts commands ${vmCommands.join("\n")}`, LogLevel.DEBUG);
// Upload the post-startup commands script file.
printLog(`Uploading VM post-startup commands script file ${actions.vmBootstrapScriptFilename}`, LogLevel.DEBUG);
await uploadFileToBucketNoFile(bucketName, `circuits/${circuit.name}/${actions.vmBootstrapScriptFilename}`, vmCommands.join("\n"));
// Compute the VM disk space requirement (in GB).
const vmDiskSize = actions.computeDiskSizeForVM(circuit.zKeySizeInBytes, circuit.metadata?.pot);
printLog(`Check VM startup commands ${startupCommand.join("\n")}`, LogLevel.DEBUG);
// Configure and instantiate a new VM based on the coordinator input.
const instance = await actions.createEC2Instance(ec2Client, startupCommand, circuit.verification.vm?.vmConfigurationType, vmDiskSize, circuit.verification.vm?.vmDiskType);
// Get the VM instance identifier.
vmInstanceId = instance.instanceId;
// Update the circuit document info accordingly.
circuit = {
...circuit,
verification: {
cfOrVm: circuit.verification.cfOrVm,
vm: {
vmConfigurationType: circuit.verification.vm?.vmConfigurationType,
vmDiskSize,
vmInstanceId
}
}
};
}
// Encode circuit data.
const encodedCircuit = htmlEncodeCircuitData(circuit);
printLog(`writing circuit data...`, LogLevel.DEBUG);
// Prepare tx to write circuit data.
batch.create(circuitDoc.ref, {
...encodedCircuit,
lastUpdated: getCurrentServerTimestampInMillis()
});
}
printLog(`Done handling circuits...`, LogLevel.DEBUG);
// Send txs in a batch (to avoid race conditions).
await batch.commit();
printLog(`Setup completed for ceremony ${ceremonyDoc.id}`, LogLevel.DEBUG);
return ceremonyDoc.id;
});
/**
* Prepare all the necessary information needed for initializing the waiting queue of a circuit.
* @dev this function will add a new field `waitingQueue` in the newly created circuit document.
*/
const initEmptyWaitingQueueForCircuit = functions__namespace
.region("europe-west1")
.runWith({
memory: "512MB"
})
.firestore.document(`/${actions.commonTerms.collections.ceremonies.name}/{ceremony}/${actions.commonTerms.collections.circuits.name}/{circuit}`)
.onCreate(async (doc) => {
// Prepare Firestore DB.
const firestore = admin.firestore();
// Get circuit document identifier and data.
const circuitId = doc.id;
// Get parent ceremony collection path.
const parentCollectionPath = doc.ref.parent.path; // == /ceremonies/{ceremony}/circuits/.
// Define an empty waiting queue.
const emptyWaitingQueue = {
contributors: [],
currentContributor: "",
completedContributions: 0,
failedContributions: 0
};
// Update the circuit document.
await firestore.collection(parentCollectionPath).doc(circuitId).set({
waitingQueue: emptyWaitingQueue,
lastUpdated: getCurrentServerTimestampInMillis()
}, { merge: true });
printLog(`An empty waiting queue has been successfully initialized for circuit ${circuitId} which belongs to ceremony ${doc.id}`, LogLevel.DEBUG);
});
/**
* Conclude the finalization of the ceremony.
* @dev checks that the ceremony is closed (= CLOSED), the coordinator is finalizing and has already
* provided the final contribution for each ceremony circuit.
*/
const finalizeCeremony = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.https.onCall(async (data, context) => {
if (!context.auth || !context.auth.token.coordinator)
logAndThrowError(COMMON_ERRORS.CM_NOT_COORDINATOR_ROLE);
if (!data.ceremonyId)
logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA);
// Prepare Firestore DB.
const firestore = admin.firestore();
const batch = firestore.batch();
// Extract data.
const { ceremonyId } = data;
const userId = context.auth?.uid;
// Look for the ceremony document.
const ceremonyDoc = await getDocumentById(actions.commonTerms.collections.ceremonies.name, ceremonyId);
const participantDoc = await getDocumentById(actions.getParticipantsCollectionPath(ceremonyId), userId);
if (!ceremonyDoc.data() || !participantDoc.data())
logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA);
// Get ceremony circuits.
const circuits = await getCeremonyCircuits(ceremonyId);
// Get final contribution for each circuit.
// nb. the `getFinalContributionDocument` checks the existence of the final contribution document (if not present, throws).
// Therefore, we just need to call the method without taking any data to verify the pre-condition of having already computed
// the final contributions for each ceremony circuit.
for await (const circuit of circuits)
await getFinalContribution(ceremonyId, circuit.id);
// Extract data.
const { state } = ceremonyDoc.data();
const { status } = participantDoc.data();
// Pre-conditions: verify the ceremony is closed and coordinator is finalizing.
if (state === "CLOSED" /* CeremonyState.CLOSED */ && status === "FINALIZING" /* ParticipantStatus.FINALIZING */) {
// Prepare txs for updates.
batch.update(ceremonyDoc.ref, { state: "FINALIZED" /* CeremonyState.FINALIZED */ });
batch.update(participantDoc.ref, {
status: "FINALIZED" /* ParticipantStatus.FINALIZED */
});
// Check for VM termination (if any).
for (const circuit of circuits) {
const circuitData = circuit.data();
const { verification } = circuitData;
if (verification.cfOrVm === "VM" /* CircuitContributionVerificationMechanism.VM */) {
// Prepare EC2 client.
const ec2Client = await createEC2Client();
const { vm } = verification;
await actions.terminateEC2Instance(ec2Client, vm.vmInstanceId);
}
}
// Send txs.
await batch.commit();
printLog(`Ceremony ${ceremonyDoc.id} correctly finalized - Coordinator ${participantDoc.id}`, LogLevel.INFO);
}
else
logAndThrowError(SPECIFIC_ERRORS.SE_CEREMONY_CANNOT_FINALIZE_CEREMONY);
});
dotenv.config();
/**
* Check the user's current participant status for the ceremony.
* @notice this cloud function has several tasks:
* 1) Check if the authenticated user is a participant
* 1.A) If not, register it has new participant for the ceremony.
* 1.B) Otherwise:
* 2.A) Check if already contributed to all circuits or,
* 3.A) If already contributed, return false
* 2.B) Check if it has a timeout in progress
* 3.B) If timeout expired, allows the participant to resume the contribution and remove stale/outdated
* temporary data.
* 3.C) Otherwise, return false.
* 2.C) Check if there are temporary stale contribution data if the contributor has interrupted the contribution
* while completing the `COMPUTING` step and, if any, delete them.
* 1.D) If no timeout / participant already exist, just return true.
* @dev true when the participant can participate (1.A, 3.B, 1.D); otherwise false.
*/
const checkParticipantForCeremony = functions__namespace
.region("europe-west1")
.runWith({
memory: "1GB"
})
.https.onCall(async (data, context) => {
if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
logAndThrowError(SPECIFIC_ERRORS.SE_AUTH_NO_CURRENT_AUTH_USER);
if (!data.ceremonyId)
logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA);
// Prepare Firestore DB.
const firestore$1 = admin.firestore();
// Get data.
const { ceremonyId } = data;
const userId = context.auth?.uid;
// Look for the ceremony document.
const ceremonyDoc = await getDocumentById(actions.commonTerms.collections.ceremonies.name, ceremonyId);
// Extract data.
const ceremonyData = ceremonyDoc.data();
const { state } = ceremonyData;
if (!ceremonyData)
logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA);
// Check pre-condition (ceremony state opened).
if (state !== "OPENED" /* CeremonyState.OPENED */)
logAndThrowError(SPECIFIC_ERRORS.SE_PARTICIPANT_CEREMONY_NOT_OPENED);
// Check (1).
// nb. do not use `getDocumentById()` here as we need the falsy condition.
const participantDoc = await firestore$1.collection(actions.getParticipantsCollectionPath(ceremonyId)).doc(userId).get();
if (!participantDoc.exists) {
// Action (1.A).
const participantData = {
userId: participantDoc.id,
status: "WAITING" /* ParticipantStatus.WAITING */,
contributionProgress: 0,
contributionStartedAt: 0,
contributions: [],
lastUpdated: getCurrentServerTimestampInMillis()
};
// Register user as participant.
await participantDoc.ref.set(participantData);
printLog(`The user ${userId} has been registered as participant for ceremony ${ceremonyDoc.id}`, LogLevel.DEBUG);
return true;
}
// Check (1.