@devtion/backend 0.0.0-004e6ad

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/dist/src/functions/index.js +2884 -0
  4. package/dist/src/functions/index.mjs +2834 -0
  5. package/dist/types/functions/bandada.d.ts +4 -0
  6. package/dist/types/functions/bandada.d.ts.map +1 -0
  7. package/dist/types/functions/ceremony.d.ts +33 -0
  8. package/dist/types/functions/ceremony.d.ts.map +1 -0
  9. package/dist/types/functions/circuit.d.ts +63 -0
  10. package/dist/types/functions/circuit.d.ts.map +1 -0
  11. package/dist/types/functions/index.d.ts +9 -0
  12. package/dist/types/functions/index.d.ts.map +1 -0
  13. package/dist/types/functions/participant.d.ts +58 -0
  14. package/dist/types/functions/participant.d.ts.map +1 -0
  15. package/dist/types/functions/siwe.d.ts +4 -0
  16. package/dist/types/functions/siwe.d.ts.map +1 -0
  17. package/dist/types/functions/storage.d.ts +37 -0
  18. package/dist/types/functions/storage.d.ts.map +1 -0
  19. package/dist/types/functions/timeout.d.ts +26 -0
  20. package/dist/types/functions/timeout.d.ts.map +1 -0
  21. package/dist/types/functions/user.d.ts +15 -0
  22. package/dist/types/functions/user.d.ts.map +1 -0
  23. package/dist/types/lib/errors.d.ts +76 -0
  24. package/dist/types/lib/errors.d.ts.map +1 -0
  25. package/dist/types/lib/services.d.ts +16 -0
  26. package/dist/types/lib/services.d.ts.map +1 -0
  27. package/dist/types/lib/utils.d.ts +141 -0
  28. package/dist/types/lib/utils.d.ts.map +1 -0
  29. package/dist/types/types/enums.d.ts +13 -0
  30. package/dist/types/types/enums.d.ts.map +1 -0
  31. package/dist/types/types/index.d.ts +186 -0
  32. package/dist/types/types/index.d.ts.map +1 -0
  33. package/package.json +90 -0
  34. package/src/functions/bandada.ts +155 -0
  35. package/src/functions/ceremony.ts +338 -0
  36. package/src/functions/circuit.ts +1044 -0
  37. package/src/functions/index.ts +38 -0
  38. package/src/functions/participant.ts +526 -0
  39. package/src/functions/siwe.ts +77 -0
  40. package/src/functions/storage.ts +551 -0
  41. package/src/functions/timeout.ts +296 -0
  42. package/src/functions/user.ts +167 -0
  43. package/src/lib/errors.ts +242 -0
  44. package/src/lib/services.ts +64 -0
  45. package/src/lib/utils.ts +474 -0
  46. package/src/types/declarations.d.ts +1 -0
  47. package/src/types/enums.ts +12 -0
  48. package/src/types/index.ts +200 -0
  49. package/test/index.test.ts +62 -0
@@ -0,0 +1,296 @@
1
+ import * as functions from "firebase-functions"
2
+ import admin from "firebase-admin"
3
+ import dotenv from "dotenv"
4
+ import {
5
+ CeremonyTimeoutType,
6
+ getParticipantsCollectionPath,
7
+ ParticipantContributionStep,
8
+ TimeoutType,
9
+ ParticipantStatus,
10
+ getTimeoutsCollectionPath,
11
+ commonTerms
12
+ } from "@devtion/actions"
13
+ import {
14
+ getCeremonyCircuits,
15
+ getCurrentServerTimestampInMillis,
16
+ getDocumentById,
17
+ queryOpenedCeremonies
18
+ } from "../lib/utils"
19
+ import { COMMON_ERRORS, logAndThrowError, printLog, SPECIFIC_ERRORS } from "../lib/errors"
20
+ import { LogLevel } from "../types/enums"
21
+
22
+ dotenv.config()
23
+
24
+ /**
25
+ * Check and remove the current contributor if it doesn't complete the contribution on the specified amount of time.
26
+ * @dev since this cloud function is executed every minute, delay problems may occur. See issue #192 (https://github.com/quadratic-funding/mpc-phase2-suite/issues/192).
27
+ * @notice the reasons why a contributor may be considered blocking are many.
28
+ * for example due to network latency, disk availability issues, un/intentional crashes, limited hardware capabilities.
29
+ * the timeout mechanism (fixed/dynamic) could also influence this decision.
30
+ * this cloud function should check each circuit and:
31
+ * A) avoid timeout if there's no current contributor for the circuit.
32
+ * B) avoid timeout if the current contributor is the first for the circuit
33
+ * and timeout mechanism type is dynamic (suggestion: coordinator should be the first contributor).
34
+ * C) check if the current contributor is a potential blocking contributor for the circuit.
35
+ * D) discriminate between blocking contributor (= when downloading, computing, uploading contribution steps)
36
+ * or verification (= verifying contribution step) timeout types.
37
+ * E) execute timeout.
38
+ * E.1) prepare next contributor (if any).
39
+ * E.2) update circuit contributors waiting queue removing the current contributor.
40
+ * E.3) assign timeout to blocking contributor (participant doc update + timeout doc).
41
+ */
42
+ export const checkAndRemoveBlockingContributor = functions
43
+ .region("europe-west1")
44
+ .runWith({
45
+ memory: "1GB"
46
+ })
47
+ .pubsub.schedule("every 1 minutes")
48
+ .onRun(async () => {
49
+ // Prepare Firestore DB.
50
+ const firestore = admin.firestore()
51
+ // Get current server timestamp in milliseconds.
52
+ const currentServerTimestamp = getCurrentServerTimestampInMillis()
53
+
54
+ // Get opened ceremonies.
55
+ const ceremonies = await queryOpenedCeremonies()
56
+
57
+ // For each ceremony.
58
+ for (const ceremony of ceremonies) {
59
+ if (!ceremony.data())
60
+ // Do not use `logAndThrowError` method to avoid the function to exit before checking every ceremony.
61
+ printLog(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA.message, LogLevel.WARN)
62
+ else {
63
+ // Get ceremony circuits.
64
+ const circuits = await getCeremonyCircuits(ceremony.id)
65
+
66
+ // Extract ceremony data.
67
+ const { timeoutType: timeoutMechanismType, penalty } = ceremony.data()!
68
+
69
+ for (const circuit of circuits) {
70
+ if (!circuit.data())
71
+ // Do not use `logAndThrowError` method to avoid the function to exit before checking every ceremony.
72
+ printLog(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA.message, LogLevel.WARN)
73
+ else {
74
+ // Extract circuit data.
75
+ const { waitingQueue, avgTimings, dynamicThreshold, fixedTimeWindow } = circuit.data()
76
+ const { contributors, currentContributor, failedContributions, completedContributions } =
77
+ waitingQueue
78
+ const {
79
+ fullContribution: avgFullContribution,
80
+ contributionComputation: avgContributionComputation,
81
+ verifyCloudFunction: avgVerifyCloudFunction
82
+ } = avgTimings
83
+
84
+ // Case (A).
85
+ if (!currentContributor)
86
+ // Do not use `logAndThrowError` method to avoid the function to exit before checking every ceremony.
87
+ printLog(
88
+ `No current contributor for circuit ${circuit.id} - ceremony ${ceremony.id}`,
89
+ LogLevel.WARN
90
+ )
91
+ else if (
92
+ avgFullContribution === 0 &&
93
+ avgContributionComputation === 0 &&
94
+ avgVerifyCloudFunction === 0 &&
95
+ completedContributions === 0 &&
96
+ timeoutMechanismType === CeremonyTimeoutType.DYNAMIC
97
+ )
98
+ printLog(
99
+ `No timeout will be executed for the first contributor to the circuit ${circuit.id} - ceremony ${ceremony.id}`,
100
+ LogLevel.WARN
101
+ )
102
+ else {
103
+ // Get current contributor document.
104
+ const participant = await getDocumentById(
105
+ getParticipantsCollectionPath(ceremony.id),
106
+ currentContributor
107
+ )
108
+
109
+ if (!participant.data())
110
+ // Do not use `logAndThrowError` method to avoid the function to exit before checking every ceremony.
111
+ printLog(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA.message, LogLevel.WARN)
112
+ else {
113
+ // Extract participant data.
114
+ const { contributionStartedAt, verificationStartedAt, contributionStep } =
115
+ participant.data()!
116
+
117
+ // Case (C).
118
+
119
+ // Compute dynamic timeout threshold.
120
+ const timeoutDynamicThreshold =
121
+ timeoutMechanismType === CeremonyTimeoutType.DYNAMIC
122
+ ? (avgFullContribution / 100) * Number(dynamicThreshold)
123
+ : 0
124
+
125
+ // Compute the timeout expiration date (in ms).
126
+ const timeoutExpirationDateInMsForBlockingContributor =
127
+ timeoutMechanismType === CeremonyTimeoutType.DYNAMIC
128
+ ? Number(contributionStartedAt) +
129
+ Number(avgFullContribution) +
130
+ Number(timeoutDynamicThreshold)
131
+ : Number(contributionStartedAt) + Number(fixedTimeWindow) * 60000 // * 60000 = convert minutes to millis.
132
+
133
+ // Case (D).
134
+ const timeoutExpirationDateInMsForVerificationCloudFunction =
135
+ contributionStep === ParticipantContributionStep.VERIFYING &&
136
+ !!verificationStartedAt
137
+ ? Number(verificationStartedAt) + 3540000 // 3540000 = 59 minutes in ms.
138
+ : 0
139
+
140
+ // Assign the timeout type.
141
+ let timeoutType: string = ""
142
+
143
+ if (
144
+ timeoutExpirationDateInMsForBlockingContributor < currentServerTimestamp &&
145
+ (contributionStep === ParticipantContributionStep.DOWNLOADING ||
146
+ contributionStep === ParticipantContributionStep.COMPUTING ||
147
+ contributionStep === ParticipantContributionStep.UPLOADING ||
148
+ contributionStep === ParticipantContributionStep.COMPLETED)
149
+ )
150
+ timeoutType = TimeoutType.BLOCKING_CONTRIBUTION
151
+
152
+ if (
153
+ timeoutExpirationDateInMsForVerificationCloudFunction > 0 &&
154
+ timeoutExpirationDateInMsForVerificationCloudFunction < currentServerTimestamp &&
155
+ contributionStep === ParticipantContributionStep.VERIFYING
156
+ )
157
+ timeoutType = TimeoutType.BLOCKING_CLOUD_FUNCTION
158
+
159
+ printLog(
160
+ `${timeoutType} detected for circuit ${circuit.id} - ceremony ${ceremony.id}`,
161
+ LogLevel.DEBUG
162
+ )
163
+
164
+ if (!timeoutType)
165
+ // Do not use `logAndThrowError` method to avoid the function to exit before checking every ceremony.
166
+ printLog(
167
+ `No timeout for circuit ${circuit.id} - ceremony ${ceremony.id}`,
168
+ LogLevel.WARN
169
+ )
170
+ else {
171
+ // Case (E).
172
+ let nextCurrentContributorId = ""
173
+
174
+ // Prepare Firestore batch of txs.
175
+ const batch = firestore.batch()
176
+
177
+ // Remove current contributor from waiting queue.
178
+ contributors.shift()
179
+
180
+ // Check if someone else is ready to start the contribution.
181
+ if (contributors.length > 0) {
182
+ // Step (E.1).
183
+
184
+ // Take the next participant to be current contributor.
185
+ nextCurrentContributorId = contributors.at(0)
186
+
187
+ // Get the document of the next current contributor.
188
+ const nextCurrentContributor = await getDocumentById(
189
+ getParticipantsCollectionPath(ceremony.id),
190
+ nextCurrentContributorId
191
+ )
192
+
193
+ // Prepare next current contributor.
194
+ batch.update(nextCurrentContributor.ref, {
195
+ status: ParticipantStatus.READY,
196
+ lastUpdated: getCurrentServerTimestampInMillis()
197
+ })
198
+ }
199
+
200
+ // Step (E.2).
201
+ // Update accordingly the waiting queue.
202
+ batch.update(circuit.ref, {
203
+ waitingQueue: {
204
+ ...waitingQueue,
205
+ contributors,
206
+ currentContributor: nextCurrentContributorId,
207
+ failedContributions: failedContributions + 1
208
+ },
209
+ lastUpdated: getCurrentServerTimestampInMillis()
210
+ })
211
+
212
+ // Step (E.3).
213
+ batch.update(participant.ref, {
214
+ status: ParticipantStatus.TIMEDOUT,
215
+ lastUpdated: getCurrentServerTimestampInMillis()
216
+ })
217
+
218
+ // Compute the timeout duration (penalty) in milliseconds.
219
+ const timeoutPenaltyInMs = Number(penalty) * 60000 // 60000 = amount of ms x minute.
220
+
221
+ // Prepare an empty doc for timeout (w/ auto-gen uid).
222
+ const timeout = await firestore
223
+ .collection(getTimeoutsCollectionPath(ceremony.id, participant.id))
224
+ .doc()
225
+ .get()
226
+
227
+ // Prepare tx to store info about the timeout.
228
+ batch.create(timeout.ref, {
229
+ type: timeoutType,
230
+ startDate: currentServerTimestamp,
231
+ endDate: currentServerTimestamp + timeoutPenaltyInMs
232
+ })
233
+
234
+ // Send atomic update for Firestore.
235
+ await batch.commit()
236
+
237
+ printLog(
238
+ `The contributor ${participant.id} has been identified as potential blocking contributor. A timeout of type ${timeoutType} has been triggered w/ a penalty of ${timeoutPenaltyInMs} ms`,
239
+ LogLevel.DEBUG
240
+ )
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+ })
249
+
250
+ /**
251
+ * Resume the contributor circuit contribution from scratch after the timeout expiration.
252
+ * @dev The participant can resume the contribution if and only if the last timeout in progress was verified as expired (status == EXHUMED).
253
+ */
254
+ export const resumeContributionAfterTimeoutExpiration = functions
255
+ .region("europe-west1")
256
+ .runWith({
257
+ memory: "1GB"
258
+ })
259
+ .https.onCall(async (data: { ceremonyId: string }, context: functions.https.CallableContext): Promise<void> => {
260
+ if (!context.auth || (!context.auth.token.participant && !context.auth.token.coordinator))
261
+ logAndThrowError(SPECIFIC_ERRORS.SE_AUTH_NO_CURRENT_AUTH_USER)
262
+
263
+ if (!data.ceremonyId) logAndThrowError(COMMON_ERRORS.CM_MISSING_OR_WRONG_INPUT_DATA)
264
+
265
+ // Get data.
266
+ const { ceremonyId } = data
267
+ const userId = context.auth?.uid
268
+
269
+ // Look for the ceremony document.
270
+ const ceremonyDoc = await getDocumentById(commonTerms.collections.ceremonies.name, ceremonyId)
271
+ const participantDoc = await getDocumentById(getParticipantsCollectionPath(ceremonyId), userId!)
272
+
273
+ // Prepare documents data.
274
+ const participantData = participantDoc.data()
275
+
276
+ if (!ceremonyDoc.data() || !participantData) logAndThrowError(COMMON_ERRORS.CM_INEXISTENT_DOCUMENT_DATA)
277
+
278
+ // Extract data.
279
+ const { contributionProgress, status } = participantData!
280
+
281
+ // Check pre-condition for resumable contribution after timeout expiration.
282
+ if (status === ParticipantStatus.EXHUMED)
283
+ await participantDoc.ref.update({
284
+ status: ParticipantStatus.READY,
285
+ lastUpdated: getCurrentServerTimestampInMillis(),
286
+ tempContributionData: {}
287
+ })
288
+ else logAndThrowError(SPECIFIC_ERRORS.SE_CONTRIBUTE_CANNOT_PROGRESS_TO_NEXT_CIRCUIT)
289
+
290
+ printLog(
291
+ `Contributor ${userId} can retry the contribution for the circuit in position ${
292
+ contributionProgress + 1
293
+ } after timeout expiration`,
294
+ LogLevel.DEBUG
295
+ )
296
+ })
@@ -0,0 +1,167 @@
1
+ import * as functions from "firebase-functions"
2
+ import { UserRecord } from "firebase-functions/v1/auth"
3
+ import admin from "firebase-admin"
4
+ import dotenv from "dotenv"
5
+ import { commonTerms, githubReputation } from "@devtion/actions"
6
+ import { encode } from "html-entities"
7
+ import { getGitHubVariables, getCurrentServerTimestampInMillis } from "../lib/utils"
8
+ import { logAndThrowError, makeError, printLog, SPECIFIC_ERRORS } from "../lib/errors"
9
+ import { LogLevel } from "../types/enums"
10
+
11
+ dotenv.config()
12
+ /**
13
+ * Record the authenticated user information inside the Firestore DB upon authentication.
14
+ * @dev the data is recorded in a new document in the `users` collection.
15
+ * @notice this method is automatically triggered upon user authentication in the Firebase app
16
+ * which uses the Firebase Authentication service.
17
+ */
18
+ export const registerAuthUser = functions
19
+ .region("europe-west1")
20
+ .runWith({
21
+ memory: "1GB"
22
+ })
23
+ .auth.user()
24
+ .onCreate(async (user: UserRecord) => {
25
+ // Get DB.
26
+ const firestore = admin.firestore()
27
+ // Get user information.
28
+ if (!user.uid) logAndThrowError(SPECIFIC_ERRORS.SE_AUTH_NO_CURRENT_AUTH_USER)
29
+ // The user object has basic properties such as display name, email, etc.
30
+ const { displayName } = user
31
+ const { email } = user
32
+ const { photoURL } = user
33
+ const { emailVerified } = user
34
+ // Metadata.
35
+ const { creationTime } = user.metadata
36
+ const { lastSignInTime } = user.metadata
37
+ // The user's ID, unique to the Firebase project. Do NOT use
38
+ // this value to authenticate with your backend server, if
39
+ // you have one. Use User.getToken() instead.
40
+ const { uid } = user
41
+ // Reference to a document using uid.
42
+ const userRef = firestore.collection(commonTerms.collections.users.name).doc(uid)
43
+ // html encode the display name (or put the ID if the name is not displayed)
44
+ const encodedDisplayName =
45
+ user.displayName === "Null" || user.displayName === null ? user.uid : encode(displayName)
46
+
47
+ // store the avatar URL of a contributor
48
+ let avatarUrl: string = ""
49
+ // we only do reputation check if the user is not a coordinator
50
+ if (
51
+ !(
52
+ email?.endsWith(`@${process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN}`) ||
53
+ email === process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN
54
+ )
55
+ ) {
56
+ const auth = admin.auth()
57
+ // if provider == github.com let's use our functions to check the user's reputation
58
+ if (user.providerData.length > 0 && user.providerData[0].providerId === "github.com") {
59
+ const vars = getGitHubVariables()
60
+
61
+ // this return true or false
62
+ try {
63
+ const { reputable, avatarUrl: avatarURL } = await githubReputation(
64
+ user.providerData[0].uid,
65
+ vars.minimumFollowing,
66
+ vars.minimumFollowers,
67
+ vars.minimumPublicRepos,
68
+ vars.minimumAge
69
+ )
70
+ if (!reputable) {
71
+ // Delete user
72
+ await auth.deleteUser(user.uid)
73
+ // Throw error
74
+ logAndThrowError(
75
+ makeError(
76
+ "permission-denied",
77
+ "The user is not allowed to sign up because their Github reputation is not high enough.",
78
+ `The user ${
79
+ user.displayName === "Null" || user.displayName === null
80
+ ? user.uid
81
+ : user.displayName
82
+ } 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.`
83
+ )
84
+ )
85
+ }
86
+ // store locally
87
+ avatarUrl = avatarURL
88
+ printLog(
89
+ `Github reputation check passed for user ${
90
+ user.displayName === "Null" || user.displayName === null ? user.uid : user.displayName
91
+ }`,
92
+ LogLevel.DEBUG
93
+ )
94
+ } catch (error: any) {
95
+ // Delete user
96
+ await auth.deleteUser(user.uid)
97
+ logAndThrowError(
98
+ makeError(
99
+ "permission-denied",
100
+ "There was an error while checking the user's Github reputation.",
101
+ `${error}`
102
+ )
103
+ )
104
+ }
105
+ }
106
+ }
107
+ // Set document (nb. we refer to providerData[0] because we use Github OAuth provider only).
108
+ // In future releases we might want to loop through the providerData array as we support
109
+ // more providers.
110
+ await userRef.set({
111
+ name: encodedDisplayName,
112
+ encodedDisplayName,
113
+ // Metadata.
114
+ creationTime,
115
+ lastSignInTime: lastSignInTime || creationTime,
116
+ // Optional.
117
+ email: email || "",
118
+ emailVerified: emailVerified || false,
119
+ photoURL: photoURL || "",
120
+ lastUpdated: getCurrentServerTimestampInMillis()
121
+ })
122
+
123
+ // we want to create a new collection for the users to store the avatars
124
+ const avatarRef = firestore.collection(commonTerms.collections.avatars.name).doc(uid)
125
+ await avatarRef.set({
126
+ avatarUrl: avatarUrl || ""
127
+ })
128
+ printLog(`Authenticated user document with identifier ${uid} has been correctly stored`, LogLevel.DEBUG)
129
+ printLog(`Authenticated user avatar with identifier ${uid} has been correctly stored`, LogLevel.DEBUG)
130
+ })
131
+ /**
132
+ * Set custom claims for role-based access control on the newly created user.
133
+ * @notice this method is automatically triggered upon user authentication in the Firebase app
134
+ * which uses the Firebase Authentication service.
135
+ */
136
+ export const processSignUpWithCustomClaims = functions
137
+ .region("europe-west1")
138
+ .runWith({
139
+ memory: "1GB"
140
+ })
141
+ .auth.user()
142
+ .onCreate(async (user: UserRecord) => {
143
+ // Get user information.
144
+ if (!user.uid) logAndThrowError(SPECIFIC_ERRORS.SE_AUTH_NO_CURRENT_AUTH_USER)
145
+ // Prepare state.
146
+ let customClaims: any
147
+ // Check if user meets role criteria to be a coordinator.
148
+ if (
149
+ user.email &&
150
+ (user.email.endsWith(`@${process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN}`) ||
151
+ user.email === process.env.CUSTOM_CLAIMS_COORDINATOR_EMAIL_ADDRESS_OR_DOMAIN)
152
+ ) {
153
+ customClaims = { coordinator: true }
154
+ printLog(`Authenticated user ${user.uid} has been identified as coordinator`, LogLevel.DEBUG)
155
+ } else {
156
+ customClaims = { participant: true }
157
+ printLog(`Authenticated user ${user.uid} has been identified as participant`, LogLevel.DEBUG)
158
+ }
159
+ try {
160
+ // Set custom user claims on this newly created user.
161
+ await admin.auth().setCustomUserClaims(user.uid, customClaims)
162
+ } catch (error: any) {
163
+ const specificError = SPECIFIC_ERRORS.SE_AUTH_SET_CUSTOM_USER_CLAIMS_FAIL
164
+ const additionalDetails = error.toString()
165
+ logAndThrowError(makeError(specificError.code, specificError.message, additionalDetails))
166
+ }
167
+ })