import { Transaction } from "ethers"; import { chainIdToNetwork } from "../../consts"; import { Hex, MultiAttestPayload, MultiRevokeArgs, SignerOrProvider, TNetwork, } from "../../types"; import { AllGapSchemas } from "../AllGapSchemas"; import { Attestation } from "../Attestation"; import { GAP } from "../GAP"; import { GapSchema } from "../GapSchema"; import { AttestationError } from "../SchemaError"; import { GapContract } from "../contract/GapContract"; import { IMilestoneResponse } from "../karma-indexer/api/types"; import { AttestationWithTx, MilestoneCompleted, IMilestoneCompleted, } from "../types/attestations"; interface _Milestone extends Milestone {} export interface IMilestone { title: string; startsAt?: number; endsAt: number; description: string; type?: string; priority?: number; } /** * Milestone class represents a milestone that can be attested to one or multiple grants. * * It provides methods to: * - Create, complete, approve, reject, and verify milestones * - Attest a milestone to a single grant * - Attest a milestone to multiple grants in a single transaction * - Complete, approve, and verify milestones across multiple grants * - Revoke multiple milestone attestations at once */ export class Milestone extends Attestation implements IMilestone { title: string; startsAt?: number; endsAt: number; description: string; completed: MilestoneCompleted; approved: MilestoneCompleted; rejected: MilestoneCompleted; cancelled: MilestoneCompleted; verified: MilestoneCompleted[] = []; type = "milestone"; priority?: number; /** * Edits this milestone by revoking the current attestation and re-attesting with updated data. * Only milestones in PENDING state (not completed, approved, or verified) can be edited. * @param signer - The signer to use for revocation and re-attestation * @param newData - Partial milestone data to update (only provided fields are overridden) * @param callback - Optional callback function for status updates */ async edit( signer: SignerOrProvider, newData: Partial, callback?: Function ): Promise { if (this.completed || this.approved || this.verified?.length || this.cancelled) { throw new AttestationError( "ATTEST_ERROR", "Cannot edit milestone that is not in PENDING state" ); } // Step 1: Revoke current attestation await this.revoke(signer, callback); // Step 2: Update data with new fields const updatedData = { ...this.data, ...newData }; this.setValues(updatedData); // Step 3: Re-attest with updated data return this.attest(signer, callback); } /** * Edits the completion of this milestone by revoking the current completion * attestation and re-completing with updated data. * Only milestones in COMPLETED state (not yet approved or verified) can have * their completion edited. * @param signer - The signer to use for revocation and re-attestation * @param newData - Updated completion data (reason and/or proofOfWork) * @param callback - Optional callback function for status updates */ async editCompletion( signer: SignerOrProvider, newData: { reason?: string; proofOfWork?: string }, callback?: Function ): Promise { if (!this.completed) { throw new AttestationError( "ATTEST_ERROR", "Milestone is not completed" ); } if (this.approved) { throw new AttestationError( "ATTEST_ERROR", "Cannot edit completion of an approved milestone" ); } if (this.verified?.length) { throw new AttestationError( "ATTEST_ERROR", "Cannot edit completion of a verified milestone" ); } // Step 1: Revoke current completion attestation await this.revokeCompletion(signer, callback); // Step 2: Re-complete with updated data const completionData: IMilestoneCompleted = { reason: newData.reason ?? this.completed?.data?.reason ?? "", proofOfWork: newData.proofOfWork ?? this.completed?.data?.proofOfWork ?? "", }; return this.complete(signer, completionData, callback); } /** * Approves this milestone. If the milestone is not completed or already approved, * it will throw an error. * @param signer * @param reason */ async approve( signer: SignerOrProvider, data?: IMilestoneCompleted, callback?: Function ) { if (!this.completed) throw new AttestationError("ATTEST_ERROR", "Milestone is not completed"); const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue( "json", JSON.stringify({ type: "approved", ...data, }) ); } else { schema.setValue("type", "approved"); schema.setValue("reason", data?.reason || ""); schema.setValue("proofOfWork", data?.proofOfWork || ""); } await this.attestStatus(signer, schema, callback); this.approved = new MilestoneCompleted({ data: { type: "approved", reason: data?.reason || "", }, refUID: this.uid, schema: schema, recipient: this.recipient, }); } /** * Approves this milestone across multiple grants. If the milestones are not completed, * it will throw an error. * @param signer - The signer to use for attestation * @param milestoneUIDs - Array of milestone UIDs to approve * @param data - Optional approval data * @param callback - Optional callback function for status updates * @returns Promise with transaction and UIDs */ async approveMultipleGrants( signer: SignerOrProvider, milestoneUIDs: Hex[], data?: IMilestoneCompleted, callback?: Function ): Promise { // Validate that all milestones are completed if (!this.completed) throw new AttestationError("ATTEST_ERROR", "Milestone is not completed"); const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue( "json", JSON.stringify({ type: "approved", ...data, }) ); } else { schema.setValue("type", "approved"); schema.setValue("reason", data?.reason || ""); schema.setValue("proofOfWork", data?.proofOfWork || ""); } // Create approval attestations for each milestone const approvalPayloads: MultiAttestPayload = []; for (const milestoneUID of milestoneUIDs) { const approved = new MilestoneCompleted({ data: { type: "approved", ...data, }, refUID: milestoneUID, schema, recipient: this.recipient, }); // Add approval to the payload approvalPayloads.push([ approved, await approved.payloadFor(0), // Index doesn't matter for approval ]); } // Attest all approvals at once const result = await GapContract.multiAttest( signer, approvalPayloads.map((p) => p[1]), callback ); // Save the first approval to this milestone instance if (result.uids.length > 0) { this.approved = new MilestoneCompleted({ data: { type: "approved", ...data, }, refUID: milestoneUIDs[0], uid: result.uids[0], schema, recipient: this.recipient, }); } return result; } /** * Revokes the approved status of the milestone. If the milestone is not approved, * it will throw an error. * @param signer */ async revokeApproval(signer: SignerOrProvider) { if (!this.approved) throw new AttestationError("ATTEST_ERROR", "Milestone is not approved"); await this.approved.schema.multiRevoke(signer, [ { schemaId: this.completed.schema.uid, uid: this.completed.uid, }, ]); } /** * Reject a completed milestone. If the milestone is not completed or already rejected, * it will throw an error. * @param signer * @param reason */ async reject(signer: SignerOrProvider, reason = "") { if (!this.completed) throw new AttestationError("ATTEST_ERROR", "Milestone is not completed"); const schema = this.schema.gap.findSchema("MilestoneCompleted"); schema.setValue("type", "rejected"); schema.setValue("reason", reason); await this.attestStatus(signer, schema); this.rejected = new MilestoneCompleted({ data: { type: "rejected", reason, }, refUID: this.uid, schema: schema, recipient: this.recipient, }); } /** * Revokes the rejected status of the milestone. If the milestone is not rejected, * it will throw an error. * @param signer */ async revokeRejection(signer: SignerOrProvider) { if (!this.rejected) throw new AttestationError("ATTEST_ERROR", "Milestone is not rejected"); const { tx, uids } = await this.rejected.schema.multiRevoke(signer, [ { schemaId: this.completed.schema.uid, uid: this.completed.uid, }, ]); return { tx, uids }; } /** * Cancels this milestone. A cancelled milestone will not be delivered and is * kept, badged "cancelled", for the program's records. Only milestones that * are not yet completed or verified can be cancelled. * @param signer * @param reason */ async cancel( signer: SignerOrProvider, reason = "", callback?: Function ): Promise { if (this.completed || this.verified?.length) throw new AttestationError( "ATTEST_ERROR", "Cannot cancel a completed or verified milestone" ); if (this.cancelled) throw new AttestationError( "ATTEST_ERROR", "Milestone is already cancelled" ); const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue("json", JSON.stringify({ type: "cancelled", reason })); } else { schema.setValue("type", "cancelled"); schema.setValue("reason", reason); } const { tx, uids } = await this.attestStatus(signer, schema, callback); this.cancelled = new MilestoneCompleted({ data: { type: "cancelled", reason, }, refUID: this.uid, uid: uids?.[0], schema: schema, recipient: this.recipient, }); return { tx, uids }; } /** * Revokes the cancelled status of the milestone (un-cancel). If the milestone * is not cancelled, it will throw an error. * @param signer */ async revokeCancel(signer: SignerOrProvider) { if (!this.cancelled) throw new AttestationError("ATTEST_ERROR", "Milestone is not cancelled"); const { tx, uids } = await this.cancelled.schema.multiRevoke(signer, [ { schemaId: this.cancelled.schema.uid, uid: this.cancelled.uid, }, ]); return { tx, uids }; } /** * Revokes multiple milestone attestations at once. * This method can be used to revoke multiple milestone attestations in a single transaction. * * @param signer - The signer to use for revocation * @param attestationsToRevoke - Array of objects containing schemaId and uid of attestations to revoke * @param callback - Optional callback function for status updates * @returns Promise with transaction and UIDs of revoked attestations */ async revokeMultipleAttestations( signer: SignerOrProvider, attestationsToRevoke: MultiRevokeArgs[], callback?: Function ): Promise { if (attestationsToRevoke.length === 0) { throw new AttestationError( "ATTEST_ERROR", "No attestations specified for revocation" ); } // Group the attestations by schema ID const groupedAttestations = attestationsToRevoke.reduce( (acc, { schemaId, uid }) => { if (!acc[schemaId]) { acc[schemaId] = []; } acc[schemaId].push(uid); return acc; }, {} as Record ); // Convert to the format expected by GapContract.multiRevoke const revocationPayload = Object.entries(groupedAttestations).map( ([schemaId, uids]) => ({ schema: schemaId, data: uids.map((uid) => ({ uid, value: 0n, // EAS contract requires a value field as BigInt })), }) ); // Use GapContract.multiRevoke directly with the correct payload format const result = await GapContract.multiRevoke(signer, revocationPayload); if (callback) { callback("confirmed"); } return result; } /** * Marks a milestone as completed. If the milestone is already completed, * it will throw an error. * @param signer * @param reason */ async complete( signer: SignerOrProvider, data?: IMilestoneCompleted, callback?: Function ): Promise { const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue( "json", JSON.stringify({ type: "completed", ...data, }) ); } else { schema.setValue("type", "completed"); schema.setValue("reason", data?.reason || ""); schema.setValue("proofOfWork", data?.proofOfWork || ""); } const { tx, uids } = await this.attestStatus(signer, schema, callback); this.completed = new MilestoneCompleted({ data: { type: "completed", ...data, }, refUID: this.uid, schema, recipient: this.recipient, }); return { tx, uids }; } /** * Marks a milestone as completed across multiple grants. If the milestone is already completed, * it will throw an error. * @param signer - The signer to use for attestation * @param grantIndices - Array of grant indices to attest this milestone to, or array of milestone UIDs * @param data - Optional completion data * @param callback - Optional callback function for status updates * @returns Promise with transaction and UIDs */ async completeForMultipleGrants( signer: SignerOrProvider, grantIndicesOrMilestoneUIDs: number[] | Hex[] = [0], data?: IMilestoneCompleted, callback?: Function ): Promise { // Check if we're dealing with UIDs instead of indices const isUids = grantIndicesOrMilestoneUIDs.length > 0 && typeof grantIndicesOrMilestoneUIDs[0] === "string"; let milestoneUIDs: Hex[] = []; if (isUids) { // We already have milestone UIDs milestoneUIDs = grantIndicesOrMilestoneUIDs as Hex[]; } else { // First attest the milestone to multiple grants if not already attested const attestResult = await this.attestToMultipleGrants( signer, grantIndicesOrMilestoneUIDs as number[], callback ); milestoneUIDs = attestResult.uids; } // Now complete the milestone for each attested milestone const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue( "json", JSON.stringify({ type: "completed", ...data, }) ); } else { schema.setValue("type", "completed"); schema.setValue("reason", data?.reason || ""); schema.setValue("proofOfWork", data?.proofOfWork || ""); } // Create completion attestations for each milestone const completionPayloads: MultiAttestPayload = []; for (let i = 0; i < milestoneUIDs.length; i++) { const milestoneUID = milestoneUIDs[i]; // Only set this.uid if we're dealing with indices and need to update the current milestone if (!isUids) { this.uid = milestoneUID; // Set the current UID to the milestone being completed } const completed = new MilestoneCompleted({ data: { type: "completed", ...data, }, refUID: milestoneUID, schema, recipient: this.recipient, }); // Add completed status to the payload completionPayloads.push([completed, await completed.payloadFor(i)]); } // Attest all completions at once const completionResult = await GapContract.multiAttest( signer, completionPayloads.map((p) => p[1]), callback ); // Save the first completion to this milestone instance if (completionResult.uids.length > 0 && !isUids) { this.completed = new MilestoneCompleted({ data: { type: "completed", ...data, }, refUID: milestoneUIDs[0], uid: completionResult.uids[0], schema, recipient: this.recipient, }); } return isUids ? completionResult // If we're using UIDs directly, just return completion result : { tx: [ ...(milestoneUIDs.length ? [{ hash: "" } as Transaction] : []), ...completionResult.tx, ], uids: [...milestoneUIDs, ...completionResult.uids], }; } /** * Revokes the completed status of the milestone. If the milestone is not completed, * it will throw an error. * @param signer */ async revokeCompletion(signer: SignerOrProvider, callback?: Function) { if (!this.completed) throw new AttestationError("ATTEST_ERROR", "Milestone is not completed"); const { tx, uids } = await this.completed.schema.multiRevoke( signer, [ { schemaId: this.completed.schema.uid, uid: this.completed.uid, }, ], callback ); return { tx, uids }; } /** * Creates the payload for a multi-attestation. * * > if Current payload is set, it'll be used as the base payload * and the project should refer to an index of the current payload, * usually the community position. * * @param payload * @param grantIdx */ async multiAttestPayload( currentPayload: MultiAttestPayload = [], grantIdx = 0 ) { this.assertPayload(); const payload = [...currentPayload]; const milestoneIdx = payload.push([this, await this.payloadFor(grantIdx)]) - 1; if (this.completed) { payload.push([ this.completed, await this.completed.payloadFor(milestoneIdx), ]); } if (this.verified.length > 0) { await Promise.all( this.verified.map(async (m) => { const payloadForMilestone = await m.payloadFor(milestoneIdx); if (Array.isArray(payloadForMilestone)) { payloadForMilestone.forEach((item) => payload.push(item)); } }) ); } return payload.slice(currentPayload.length, payload.length); } /** * Creates the payload for a multi-attestation across multiple grants. * * This method allows for the same milestone to be attested to multiple grants * in a single transaction. * * @param currentPayload - Current payload to append to * @param grantIndices - Array of grant indices to attest this milestone to * @returns The multi-attest payload with all grant attestations */ async multiGrantAttestPayload( currentPayload: MultiAttestPayload = [], grantIndices: number[] = [0] ) { this.assertPayload(); const payload = [...currentPayload]; const milestoneIndices: number[] = []; // Create milestone attestation for each grant for (const grantIdx of grantIndices) { const milestoneIdx = payload.push([this, await this.payloadFor(grantIdx)]) - 1; milestoneIndices.push(milestoneIdx); } // Add completed status if exists for each milestone if (this.completed) { for (const milestoneIdx of milestoneIndices) { payload.push([ this.completed, await this.completed.payloadFor(milestoneIdx), ]); } } // Add verifications if exist for each milestone if (this.verified.length > 0) { for (const milestoneIdx of milestoneIndices) { await Promise.all( this.verified.map(async (m) => { const payloadForMilestone = await m.payloadFor(milestoneIdx); if (Array.isArray(payloadForMilestone)) { payloadForMilestone.forEach((item) => payload.push(item)); } }) ); } } return payload.slice(currentPayload.length, payload.length); } /** * Attests this milestone to multiple grants in a single transaction. * * @param signer - The signer to use for attestation * @param grantIndices - Array of grant indices to attest this milestone to, or array of grant UIDs * @param callback - Optional callback function for status updates * @returns Promise with transaction and UIDs */ async attestToMultipleGrants( signer: SignerOrProvider, grantIndices: number[] | Hex[] = [0], callback?: Function ): Promise { this.assertPayload(); // Check if we're dealing with Hex UIDs instead of indices const isUids = grantIndices.length > 0 && typeof grantIndices[0] === "string"; if (isUids) { // Direct approach - create individual milestone instances for each grant UID const grantUIDs = grantIndices as Hex[]; const allPayloads: any[] = []; for (const grantUID of grantUIDs) { // Create a new milestone for each grant with direct reference const grantMilestone = new Milestone({ schema: this.schema, recipient: this.recipient, data: this.data, refUID: grantUID, }); // Generate payload for this grant const payload = await grantMilestone.multiAttestPayload(); // Add each item from payload to allPayloads payload.forEach((item) => allPayloads.push(item)); } // Attest all milestones in a single transaction const result = await GapContract.multiAttest( signer, allPayloads.map((p) => p[1]), callback ); return result; } else { // Original implementation using grantIndices const payload = await this.multiGrantAttestPayload( [], grantIndices as number[] ); const { uids, tx } = await GapContract.multiAttest( signer, payload.map((p) => p[1]), callback ); if (Array.isArray(uids)) { uids.forEach((uid, index) => { payload[index][0].uid = uid; }); } return { tx, uids }; } } /** * @inheritdoc */ async attest( signer: SignerOrProvider, callback?: Function ): Promise { this.assertPayload(); const payload = await this.multiAttestPayload(); const { uids, tx } = await GapContract.multiAttest( signer, payload.map((p) => p[1]), callback ); if (Array.isArray(uids)) { uids.forEach((uid, index) => { payload[index][0].uid = uid; }); } console.log(uids); return { tx, uids }; } /** * Attest the status of the milestone as approved, rejected or completed. */ private async attestStatus( signer: SignerOrProvider, schema: GapSchema, callback?: Function ): Promise { const eas = this.schema.gap.eas.connect(signer); try { if (callback) callback("preparing"); const tx = await eas.attest({ schema: schema.uid, data: { recipient: this.recipient, data: schema.encode(), refUID: this.uid, expirationTime: 0n, revocable: schema.revocable, }, }); if (callback) callback("pending"); const uid = await tx.wait(); if (callback) callback("confirmed"); console.log(uid); return { tx: [ { hash: tx.tx.hash as Hex, } as Transaction, ], uids: [uid as `0x${string}`], }; } catch (error: any) { console.error(error); throw new AttestationError("ATTEST_ERROR", error.message, error); } } static from( attestations: IMilestoneResponse[], network: TNetwork ): Milestone[] { return attestations.map((attestation) => { const milestone = new Milestone({ ...attestation, data: { ...attestation.data, }, schema: new AllGapSchemas().findSchema( "Milestone", chainIdToNetwork[attestation.chainID] as TNetwork ), chainID: attestation.chainID, }); if (attestation.completed) { milestone.completed = new MilestoneCompleted({ ...attestation.completed, data: { ...attestation.completed.data, }, schema: new AllGapSchemas().findSchema( "MilestoneCompleted", chainIdToNetwork[attestation.chainID] as TNetwork ), chainID: attestation.chainID, }); } if (attestation.approved) { milestone.approved = new MilestoneCompleted({ ...attestation.approved, data: { ...attestation.completed.data, }, schema: new AllGapSchemas().findSchema( "MilestoneCompleted", chainIdToNetwork[attestation.chainID] as TNetwork ), chainID: attestation.chainID, }); } if (attestation.rejected) { milestone.rejected = new MilestoneCompleted({ ...attestation.rejected, data: { ...attestation.completed.data, }, schema: new AllGapSchemas().findSchema( "MilestoneCompleted", chainIdToNetwork[attestation.chainID] as TNetwork ), chainID: attestation.chainID, }); } if (attestation.cancelled) { milestone.cancelled = new MilestoneCompleted({ ...attestation.cancelled, data: { ...attestation.cancelled.data, }, schema: new AllGapSchemas().findSchema( "MilestoneCompleted", chainIdToNetwork[attestation.chainID] as TNetwork ), chainID: attestation.chainID, }); } if (attestation.verified?.length > 0) { milestone.verified = attestation.verified.map( (m) => new MilestoneCompleted({ ...m, data: { ...m.data, }, schema: new AllGapSchemas().findSchema( "MilestoneCompleted", chainIdToNetwork[attestation.chainID] as TNetwork ), chainID: attestation.chainID, }) ); } return milestone; }); } /** * Verify this milestone. If the milestone is not completed or already verified, * it will throw an error. * @param signer * @param reason */ async verify( signer: SignerOrProvider, data?: IMilestoneCompleted, callback?: Function ): Promise { console.log("Verifying"); if (!this.completed) throw new AttestationError("ATTEST_ERROR", "Milestone is not completed"); const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue( "json", JSON.stringify({ type: "verified", ...data, }) ); } else { schema.setValue("type", "verified"); schema.setValue("reason", data?.reason || ""); schema.setValue("proofOfWork", data?.proofOfWork || ""); } console.log("Before attestStatus"); const { tx, uids } = await this.attestStatus(signer, schema, callback); console.log("After attestStatus"); this.verified.push( new MilestoneCompleted({ data: { type: "verified", ...data, }, refUID: this.uid, schema: schema, recipient: this.recipient, }) ); return { tx, uids }; } /** * Verifies this milestone across multiple grants. If the milestones are not completed, * it will throw an error. * @param signer - The signer to use for attestation * @param milestoneUIDs - Array of milestone UIDs to verify * @param data - Optional verification data * @param callback - Optional callback function for status updates * @returns Promise with transaction and UIDs */ async verifyMultipleGrants( signer: SignerOrProvider, milestoneUIDs: Hex[], data?: IMilestoneCompleted, callback?: Function ): Promise { // Validate that all milestones are completed if (!this.completed) throw new AttestationError("ATTEST_ERROR", "Milestone is not completed"); const schema = this.schema.gap.findSchema("MilestoneCompleted"); if (this.schema.isJsonSchema()) { schema.setValue( "json", JSON.stringify({ type: "verified", ...data, }) ); } else { schema.setValue("type", "verified"); schema.setValue("reason", data?.reason || ""); schema.setValue("proofOfWork", data?.proofOfWork || ""); } // Create verification attestations for each milestone const verificationPayloads: MultiAttestPayload = []; for (const milestoneUID of milestoneUIDs) { const verified = new MilestoneCompleted({ data: { type: "verified", ...data, }, refUID: milestoneUID, schema, recipient: this.recipient, }); // Add verification to the payload verificationPayloads.push([ verified, await verified.payloadFor(0), // Index doesn't matter for verification ]); } // Attest all verifications at once const result = await GapContract.multiAttest( signer, verificationPayloads.map((p) => p[1]), callback ); // Save the verifications to this milestone instance if (result.uids.length > 0) { for (let i = 0; i < result.uids.length; i++) { this.verified.push( new MilestoneCompleted({ data: { type: "verified", ...data, }, refUID: milestoneUIDs[i], uid: result.uids[i], schema, recipient: this.recipient, }) ); } } return result; } }