From 837037dac699184d93abae463fd191be2ffab608 Mon Sep 17 00:00:00 2001 From: nutrina Date: Fri, 7 Jul 2023 02:33:02 +0300 Subject: [PATCH] feat(iam): add support for passport attestations (#1415) * feat(iam): add support for passport attestations * feat(iam): fixing linter complaints * feat(iam): save providers to bitmap and include expiration dates * feat(app): use eas/passport endpoint to bring stamps on chain * chore(iam): update and test sorting and bitmap operations for on chain passport * chore(iam): check sorting of issuance and expiration dates * feat(iam): build bit map from static stampMetadata json * chore(iam): unit test eas/passport * chore(iam): remove outdated test * feat(iam): statically generate stamp metada info from script * feat(iam): include bitMap version in eas schema --------- Co-authored-by: schultztimothy --- app/components/SyncToChainButton.tsx | 2 +- iam/.env-example.env | 4 + iam/__tests__/easPassportSchema.test.ts | 270 ++++++++++++++++++ ...sSchema.test.ts => easStampSchema.test.ts} | 2 +- iam/__tests__/index.test.ts | 186 +++++++++++- iam/src/index.ts | 75 ++++- iam/src/scripts/buildProviderBitMapInfo.ts | 31 ++ iam/src/static/providerBitMapInfo.json | 1 + iam/src/utils/easPassportSchema.ts | 217 ++++++++++++++ .../utils/{easSchema.ts => easStampSchema.ts} | 0 iam/src/utils/scorerService.ts | 2 +- iam/tsconfig.json | 1 + 12 files changed, 784 insertions(+), 7 deletions(-) create mode 100644 iam/__tests__/easPassportSchema.test.ts rename iam/__tests__/{easSchema.test.ts => easStampSchema.test.ts} (98%) create mode 100644 iam/src/scripts/buildProviderBitMapInfo.ts create mode 100644 iam/src/static/providerBitMapInfo.json create mode 100644 iam/src/utils/easPassportSchema.ts rename iam/src/utils/{easSchema.ts => easStampSchema.ts} (100%) diff --git a/app/components/SyncToChainButton.tsx b/app/components/SyncToChainButton.tsx index d80da65cce..13899d46c9 100644 --- a/app/components/SyncToChainButton.tsx +++ b/app/components/SyncToChainButton.tsx @@ -111,7 +111,7 @@ const SyncToChainButton = () => { const { data }: { data: EasPayload } = await axios({ method: "post", - url: `${process.env.NEXT_PUBLIC_PASSPORT_IAM_URL}v0.0.0/eas`, + url: `${process.env.NEXT_PUBLIC_PASSPORT_IAM_URL}v0.0.0/eas/passport`, data: payload, headers: { "Content-Type": "application/json", diff --git a/iam/.env-example.env b/iam/.env-example.env index f02677b5bc..c4289c5435 100644 --- a/iam/.env-example.env +++ b/iam/.env-example.env @@ -52,5 +52,9 @@ SCORER_API_KEY=abc EAS_GITCOIN_STAMP_SCHEMA=0xd83994d5459162a259c3a18d3db267ca05982f1e1e261d5388a8bfe2a2a2c7f9 EAS_GITCOIN_SCORE_SCHEMA=0xd43bc76510fcedd3ee4cac1a439ed4a130736b8faba0f4a814050e948942cdc0 +EAS_GITCOIN_PASSPORT_SCHEMA=0x49d9700408d4ed2c132a3d6410ada3ed794e9d927accfdc02a9ceae75de7af97 FF_NEW_GITHUB_STAMPS=on + +PASSPORT_STAMP_METADATA_PATH=http://localhost:3000/stampMetadata.json + diff --git a/iam/__tests__/easPassportSchema.test.ts b/iam/__tests__/easPassportSchema.test.ts new file mode 100644 index 0000000000..7f92533dcb --- /dev/null +++ b/iam/__tests__/easPassportSchema.test.ts @@ -0,0 +1,270 @@ +import * as easPassportModule from "../src/utils/easPassportSchema"; +import * as easStampModule from "../src/utils/easStampSchema"; + +import { VerifiableCredential } from "@gitcoin/passport-types"; +import { BigNumber } from "ethers"; +import { NO_EXPIRATION, ZERO_BYTES32 } from "@ethereum-attestation-service/eas-sdk"; + +jest.mock("../src/utils/scorerService", () => ({ + fetchPassportScore: jest.fn(), +})); + +const defaultRequestData = { + recipient: "0x123", + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: ZERO_BYTES32, + value: 0, +}; + +describe("formatMultiAttestationRequest", () => { + it("should return formatted attestation request", async () => { + jest.spyOn(easPassportModule, "encodeEasPassport").mockReturnValue("0x00000000000000000000000"); + jest.spyOn(easStampModule, "encodeEasScore").mockReturnValue("0x00000000000000000000000"); + + const validatedCredentials = [ + { + credential: { + credentialSubject: { + provider: "mockCredential1", + hash: "v0.0.0:QdjFB8E6FbvBT8HP+4mr7VBjal+CC7aDcAAqGAKsXos=", + }, + issuanceDate: "2023-05-10T11:00:14.986Z", + expirationDate: "2023-08-10T11:00:14.986Z", + } as unknown as VerifiableCredential, + verified: true, + }, + { + credential: { + credentialSubject: { + provider: "mockCredential2", + hash: "v0.0.0:QdjFB8E6FbvBT8HP+4mr7VBjal+CC7aDcAAqGAKsXos=", + }, + issuanceDate: "2023-05-10T11:00:14.986Z", + expirationDate: "2023-08-10T11:00:14.986Z", + } as unknown as VerifiableCredential, + verified: false, + }, + ]; + + const recipient = "0x123"; + + const result = await easPassportModule.formatMultiAttestationRequest(validatedCredentials, recipient); + + expect(result).toEqual([ + { + schema: process.env.EAS_GITCOIN_PASSPORT_SCHEMA, + data: [ + { + ...defaultRequestData, + data: "0x00000000000000000000000", + }, + ], + }, + { + schema: process.env.EAS_GITCOIN_SCORE_SCHEMA, + data: [ + { + ...defaultRequestData, + data: "0x00000000000000000000000", + }, + ], + }, + ]); + }); +}); + +describe("formatPassportAttestationData", () => { + it("should format attestation data correctly", async () => { + // Prepare a mock credential + const mockCredential: VerifiableCredential = { + "@context": [], + type: [], + credentialSubject: { + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + provider: "mockProvider", + id: "", + "@context": [{}], + }, + issuer: "string", + issuanceDate: "2023-01-01T00:00:00.000Z", + expirationDate: "2023-12-31T23:59:59.999Z", + proof: { + type: "string", + proofPurpose: "string", + verificationMethod: "string", + created: "string", + jws: "string", + }, + }; + + // Prepare a mock stamp + const mockStamp = { + name: "mockProvider", + index: 0, + bit: 1, + }; + + const passportAttestationStampMap: Map = new Map(); + + passportAttestationStampMap.set(mockCredential.credentialSubject.provider, mockStamp); + + jest.spyOn(easPassportModule, "buildProviderBitMap").mockReturnValue(passportAttestationStampMap); + + const result: easPassportModule.PassportAttestationData = await easPassportModule.formatPassportAttestationData([ + mockCredential, + ]); + + // Check that the resulting providers array has the expected value + // For the mockStamp, it should be [BigNumber.from(1 << 1)] + expect(result.providers).toEqual([BigNumber.from(1 << mockStamp.bit)]); + + // Check that the info array contains an object with the expected values + expect(result.info).toHaveLength(1); + expect(result.info[0]).toMatchObject({ + hash: "0x" + Buffer.from(mockCredential.credentialSubject.hash.split(":")[1], "base64").toString("hex"), + issuanceDate: BigNumber.from(Math.floor(new Date(mockCredential.issuanceDate).getTime() / 1000)), + expirationDate: BigNumber.from(Math.floor(new Date(mockCredential.expirationDate).getTime() / 1000)), + stampInfo: mockStamp, + }); + }); + it("should return multiple provider bitmaps/bns if index exceeds range", async () => { + // Prepare a mock credential + const mockCredential = { + credentialSubject: { + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + provider: "mockProvider", + }, + issuer: "string", + issuanceDate: "2023-01-01T00:00:00.000Z", + expirationDate: "2023-12-31T23:59:59.999Z", + } as unknown as VerifiableCredential; + + const mockCredential1 = { + credentialSubject: { + hash: "v0.0.0:QdjFB8E6FbvBT8HP+4mr7VBjal+CC7aDcAAqGAKsXos=", + provider: "mockProvider1", + }, + issuer: "string", + issuanceDate: "2023-01-01T00:00:00.000Z", + expirationDate: "2023-12-31T23:59:59.999Z", + } as unknown as VerifiableCredential; + + // Prepare a mock stamp + const mockStamp = { + name: "mockProvider", + index: 0, + bit: 1, + }; + + const mockStamp1 = { + name: "mockProvider1", + index: 1, + bit: 0, + }; + + const passportAttestationStampMap: Map = new Map(); + + // Add the mock stamps to the map + passportAttestationStampMap.set(mockCredential.credentialSubject.provider, mockStamp); + passportAttestationStampMap.set(mockCredential1.credentialSubject.provider, mockStamp1); + + jest.spyOn(easPassportModule, "buildProviderBitMap").mockReturnValue(passportAttestationStampMap); + + const result: easPassportModule.PassportAttestationData = await easPassportModule.formatPassportAttestationData([ + mockCredential, + mockCredential1, + ]); + + expect(result.providers).toEqual([BigNumber.from(1 << mockStamp.bit), BigNumber.from(1 << mockStamp1.bit)]); + + // Check that the info array contains an object with the expected values + expect(result.info).toHaveLength(2); + expect(result.info[0]).toMatchObject({ + hash: "0x" + Buffer.from(mockCredential.credentialSubject.hash.split(":")[1], "base64").toString("hex"), + issuanceDate: BigNumber.from(Math.floor(new Date(mockCredential.issuanceDate).getTime() / 1000)), + expirationDate: BigNumber.from(Math.floor(new Date(mockCredential.expirationDate).getTime() / 1000)), + stampInfo: mockStamp, + }); + }); +}); + +describe("sortPassportAttestationData", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + it("should correctly sort the Attestation data", () => { + const stamp1 = { + name: "TestStamp1", + index: 2, + bit: 1, + }; + + const stamp2 = { + name: "TestStamp2", + index: 1, + bit: 2, + }; + + const stamp3 = { + name: "TestStamp3", + index: 2, + bit: 0, + }; + + const attestation: easPassportModule.PassportAttestationData = { + providers: [BigNumber.from(3), BigNumber.from(1), BigNumber.from(2)], + info: [ + { + hash: "0x123", + issuanceDate: BigNumber.from(1000), + expirationDate: BigNumber.from(2000), + stampInfo: stamp1, + }, + { + hash: "0x456", + issuanceDate: BigNumber.from(1001), + expirationDate: BigNumber.from(2001), + stampInfo: stamp2, + }, + { + hash: "0x789", + issuanceDate: BigNumber.from(1002), + expirationDate: BigNumber.from(2002), + stampInfo: stamp3, + }, + ], + }; + + const sortedAttestation: easPassportModule.AttestationData = + easPassportModule.sortPassportAttestationData(attestation); + + expect(sortedAttestation.hashes).toEqual(["0x456", "0x789", "0x123"]); + expect(sortedAttestation.issuancesDates).toEqual([ + BigNumber.from(1001), + BigNumber.from(1002), + BigNumber.from(1000), + ]); + expect(sortedAttestation.expirationDates).toEqual([ + BigNumber.from(2001), + BigNumber.from(2002), + BigNumber.from(2000), + ]); + }); +}); + +describe("buildProviderBitMapInfo", () => { + it("should correctly utilize the first bit of the new bitmap when a new bitmap is created", async () => { + const stampNames = Array(257) + .fill(null) + .map((_, idx) => `stamp${idx}`); + const groupStamps = stampNames.map((name) => ({ name })); + const group = { name: "group1", stamps: groupStamps }; + const stampMetadata: easPassportModule.StampMetadata = [{ id: "1", name: "metadata1", groups: [group] }]; + + const bitmapInfo = easPassportModule.mapBitMapInfo(stampMetadata); + + expect(bitmapInfo[bitmapInfo.length - 2]).toEqual({ bit: 255, index: 0, name: "stamp255" }); + expect(bitmapInfo[bitmapInfo.length - 1]).toEqual({ bit: 0, index: 1, name: "stamp256" }); + }); +}); diff --git a/iam/__tests__/easSchema.test.ts b/iam/__tests__/easStampSchema.test.ts similarity index 98% rename from iam/__tests__/easSchema.test.ts rename to iam/__tests__/easStampSchema.test.ts index accf4b83c8..d9f64e8fb8 100644 --- a/iam/__tests__/easSchema.test.ts +++ b/iam/__tests__/easStampSchema.test.ts @@ -1,4 +1,4 @@ -import * as easStampModule from "../src/utils/easSchema"; +import * as easStampModule from "../src/utils/easStampSchema"; import { VerifiableCredential } from "@gitcoin/passport-types"; import { BigNumber } from "ethers"; import { NO_EXPIRATION, ZERO_BYTES32 } from "@ethereum-attestation-service/eas-sdk"; diff --git a/iam/__tests__/index.test.ts b/iam/__tests__/index.test.ts index 3405739e51..73adb5c90a 100644 --- a/iam/__tests__/index.test.ts +++ b/iam/__tests__/index.test.ts @@ -33,7 +33,8 @@ import { MultiAttestationRequest, ZERO_BYTES32, NO_EXPIRATION } from "@ethereum- import { utils } from "ethers"; import * as easFeesMock from "../src/utils/easFees"; import * as identityMock from "@gitcoin/passport-identity/dist/commonjs/src/credentials"; -import * as easSchemaMock from "../src/utils/easSchema"; +import * as easSchemaMock from "../src/utils/easStampSchema"; +import * as easPassportSchemaMock from "../src/utils/easPassportSchema"; jest.mock("ethers", () => { const originalModule = jest.requireActual("ethers"); @@ -980,3 +981,186 @@ describe("POST /eas", () => { expect(response.body.error).toBe("Every credential's id must be equivalent"); }); }); + +describe("POST /eas/passport", () => { + let verifyCredentialSpy: jest.SpyInstance; + let formatMultiAttestationRequestSpy: jest.SpyInstance; + + beforeEach(() => { + verifyCredentialSpy = jest.spyOn(identityMock, "verifyCredential").mockResolvedValue(true); + formatMultiAttestationRequestSpy = jest + .spyOn(easPassportSchemaMock, "formatMultiAttestationRequest") + .mockResolvedValue(mockMultiAttestationRequest); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("handles missing stamps in the request body", async () => { + const nonce = 0; + const credentials: VerifiableCredential[] = []; + const response = await request(app) + .post("/api/v0.0.0/eas/passport") + .send({ credentials, nonce }) + .set("Accept", "application/json") + .expect(400) + .expect("Content-Type", /json/); + + expect(response.body.error).toEqual("No stamps provided"); + }); + + it("handles invalid recipient in the request body", async () => { + const nonce = 0; + const credentials = [ + { + "@context": "https://www.w3.org/2018/credentials/v1", + type: ["VerifiableCredential", "Stamp"], + issuer: config.issuer, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: "did:pkh:eip155:1:0x5678", + provider: "test", + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + }, + expirationDate: "9999-12-31T23:59:59Z", + }, + ]; + + const response = await request(app) + .post("/api/v0.0.0/eas/passport") + .send({ credentials, nonce }) + .set("Accept", "application/json") + .expect(400) + .expect("Content-Type", /json/); + + expect(response.body.error).toEqual("Invalid recipient"); + }); + + it("should throw a 400 error if every credentialSubject.id is not equivalent", async () => { + const nonce = 0; + const credentials = [ + { + "@context": "https://www.w3.org/2018/credentials/v1", + type: ["VerifiableCredential", "Stamp"], + issuer: config.issuer, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: "did:pkh:eip155:1:0x5678000000000000000000000000000000000000", + provider: "test", + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + }, + expirationDate: "9999-12-31T23:59:59Z", + }, + { + "@context": "https://www.w3.org/2018/credentials/v1", + type: ["VerifiableCredential", "Stamp"], + issuer: config.issuer, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: "did:pkh:eip155:1:0x5678000000000000000000000000000000000001", + provider: "test1", + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + }, + expirationDate: "9999-12-31T23:59:59Z", + }, + ]; + + const response = await request(app) + .post("/api/v0.0.0/eas/passport") + .send({ credentials, nonce }) + .set("Accept", "application/json") + .expect(400) + .expect("Content-Type", /json/); + + expect(response.body.error).toEqual("Every credential's id must be equivalent"); + }); + + it("successfully verifies and formats passport", async () => { + const nonce = 0; + const credentials = [ + { + "@context": "https://www.w3.org/2018/credentials/v1", + type: ["VerifiableCredential", "Stamp"], + issuer: config.issuer, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: "did:pkh:eip155:1:0x5678000000000000000000000000000000000000", + provider: "test", + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + }, + expirationDate: "9999-12-31T23:59:59Z", + }, + ]; + + const response = await request(app) + .post("/api/v0.0.0/eas/passport") + .send({ credentials, nonce }) + .set("Accept", "application/json") + .expect(200) + .expect("Content-Type", /json/); + + expect(response.body.passport.multiAttestationRequest).toEqual(mockMultiAttestationRequest); + expect(response.body.passport.nonce).toEqual(nonce); + expect(verifyCredentialSpy).toBeCalledTimes(credentials.length); + expect(formatMultiAttestationRequestSpy).toBeCalled(); + }); + + it("handles error during the formatting of the passport", async () => { + formatMultiAttestationRequestSpy.mockRejectedValue(new Error("Formatting error")); + + const nonce = 0; + const credentials = [ + { + "@context": "https://www.w3.org/2018/credentials/v1", + type: ["VerifiableCredential", "Stamp"], + issuer: config.issuer, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: "did:pkh:eip155:1:0x5678000000000000000000000000000000000000", + provider: "test", + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + }, + expirationDate: "9999-12-31T23:59:59Z", + }, + ]; + + const response = await request(app) + .post("/api/v0.0.0/eas/passport") + .send({ credentials, nonce }) + .set("Accept", "application/json") + .expect(500) + .expect("Content-Type", /json/); + + expect(response.body.error).toEqual("Error formatting onchain passport"); + }); + + it("handles error during credential verification", async () => { + verifyCredentialSpy.mockRejectedValue(new Error("Verification error")); + + const nonce = 0; + const credentials = [ + { + "@context": "https://www.w3.org/2018/credentials/v1", + type: ["VerifiableCredential", "Stamp"], + issuer: config.issuer, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: "did:pkh:eip155:1:0x5678000000000000000000000000000000000000", + provider: "test", + hash: "v0.0.0:8JZcQJy6uwNGPDZnvfGbEs6mf5OZVD1mUOdhKNrOHls=", + }, + expirationDate: "9999-12-31T23:59:59Z", + }, + ]; + + const response = await request(app) + .post("/api/v0.0.0/eas/passport") + .send({ credentials, nonce }) + .set("Accept", "application/json") + .expect(500) + .expect("Content-Type", /json/); + + expect(response.body.error).toEqual("Error formatting onchain passport"); + }); +}); diff --git a/iam/src/index.ts b/iam/src/index.ts index 1cf1f8d589..5e838691df 100644 --- a/iam/src/index.ts +++ b/iam/src/index.ts @@ -32,7 +32,8 @@ import { import { getChallenge } from "./utils/challenge"; import { getEASFeeAmount } from "./utils/easFees"; -import { formatMultiAttestationRequest } from "./utils/easSchema"; +import * as stampSchema from "./utils/easStampSchema"; +import * as passportSchema from "./utils/easPassportSchema"; // ---- Generate & Verify methods import * as DIDKit from "@spruceid/didkit-wasm-node"; @@ -406,7 +407,7 @@ app.post("/api/v0.0.0/verify", (req: Request, res: Response): void => { }); }); -// Expose entry point for getting eas payload for moving stamps on-chain +// Expose entry point for getting eas payload for moving stamps on-chain (Stamp Attestations) // This function will receive an array of stamps, validate them and return an array of eas payloads app.post("/api/v0.0.0/eas", (req: Request, res: Response): void => { try { @@ -434,7 +435,75 @@ app.post("/api/v0.0.0/eas", (req: Request, res: Response): void => { .filter(({ verified }) => !verified) .map(({ credential }) => credential); - const multiAttestationRequest = await formatMultiAttestationRequest(credentialVerifications, recipient); + const multiAttestationRequest = await stampSchema.formatMultiAttestationRequest( + credentialVerifications, + recipient + ); + + const fee = await getEASFeeAmount(2); + const passportAttestation: PassportAttestation = { + multiAttestationRequest, + nonce: Number(nonce), + fee: fee.toString(), + }; + + attestationSignerWallet + ._signTypedData(ATTESTER_DOMAIN, ATTESTER_TYPES, passportAttestation) + .then((signature) => { + const { v, r, s } = utils.splitSignature(signature); + + const payload: EasPayload = { + passport: passportAttestation, + signature: { v, r, s }, + invalidCredentials, + }; + + return void res.json(payload); + }) + .catch(() => { + return void errorRes(res, "Error signing passport", 500); + }); + }) + .catch(() => { + return void errorRes(res, "Error formatting onchain passport", 500); + }); + } catch (error) { + return void errorRes(res, String(error), 500); + } +}); + +// Expose entry point for getting eas payload for moving stamps on-chain (Passport Attestations) +// This function will receive an array of stamps, validate them and return an array of eas payloads +app.post("/api/v0.0.0/eas/passport", (req: Request, res: Response): void => { + try { + const { credentials, nonce } = req.body as EasRequestBody; + if (!credentials.length) return void errorRes(res, "No stamps provided", 400); + + const recipient = credentials[0].credentialSubject.id.split(":")[4]; + + if (!(recipient && recipient.length === 42 && recipient.startsWith("0x"))) + return void errorRes(res, "Invalid recipient", 400); + + if (!credentials.every((credential) => credential.credentialSubject.id.split(":")[4] === recipient)) + return void errorRes(res, "Every credential's id must be equivalent", 400); + + Promise.all( + credentials.map(async (credential) => { + return { + credential, + verified: issuer === credential.issuer && (await verifyCredential(DIDKit, credential)), + }; + }) + ) + .then(async (credentialVerifications) => { + const invalidCredentials = credentialVerifications + .filter(({ verified }) => !verified) + .map(({ credential }) => credential); + + const multiAttestationRequest = await passportSchema.formatMultiAttestationRequest( + credentialVerifications, + recipient + ); const fee = await getEASFeeAmount(2); const passportAttestation: PassportAttestation = { diff --git a/iam/src/scripts/buildProviderBitMapInfo.ts b/iam/src/scripts/buildProviderBitMapInfo.ts new file mode 100644 index 0000000000..18d9db7ae9 --- /dev/null +++ b/iam/src/scripts/buildProviderBitMapInfo.ts @@ -0,0 +1,31 @@ +import dotenv from "dotenv"; +import { writeFileSync } from "fs"; +import { join } from "path"; +import axios from "axios"; + +import { StampMetadata, mapBitMapInfo } from "../utils/easPassportSchema"; + +dotenv.config(); + +const stampMetadataEndpoint = process.env.PASSPORT_STAMP_METADATA_PATH || ""; + +const formatProviderBitMapInfo = async (): Promise => { + const stampMetadata: { + data: StampMetadata; + } = await axios.get(stampMetadataEndpoint); + + const bitMapInfo = mapBitMapInfo(stampMetadata.data); + + const outPath = join(__dirname, "..", "static", "providerBitMapInfo.json"); + console.log(`Saving platform info to JSON file at ${outPath}`); + + writeFileSync(outPath, JSON.stringify(bitMapInfo)); +}; + +formatProviderBitMapInfo() + .catch((err) => { + console.error(err); + }) + .finally(() => { + console.log("Done! BitMap info saved"); + }); diff --git a/iam/src/static/providerBitMapInfo.json b/iam/src/static/providerBitMapInfo.json new file mode 100644 index 0000000000..61bfa17eee --- /dev/null +++ b/iam/src/static/providerBitMapInfo.json @@ -0,0 +1 @@ +[{"bit":0,"index":0,"name":"gtcPossessionsGte#10"},{"bit":1,"index":0,"name":"gtcPossessionsGte#100"},{"bit":2,"index":0,"name":"SelfStakingBronze"},{"bit":3,"index":0,"name":"SelfStakingSilver"},{"bit":4,"index":0,"name":"SelfStakingGold"},{"bit":5,"index":0,"name":"CommunityStakingBronze"},{"bit":6,"index":0,"name":"CommunityStakingSilver"},{"bit":7,"index":0,"name":"CommunityStakingGold"},{"bit":8,"index":0,"name":"GitcoinContributorStatistics#numGrantsContributeToGte#1"},{"bit":9,"index":0,"name":"GitcoinContributorStatistics#numGrantsContributeToGte#10"},{"bit":10,"index":0,"name":"GitcoinContributorStatistics#numGrantsContributeToGte#25"},{"bit":11,"index":0,"name":"GitcoinContributorStatistics#numGrantsContributeToGte#100"},{"bit":12,"index":0,"name":"GitcoinContributorStatistics#totalContributionAmountGte#10"},{"bit":13,"index":0,"name":"GitcoinContributorStatistics#totalContributionAmountGte#100"},{"bit":14,"index":0,"name":"GitcoinContributorStatistics#totalContributionAmountGte#1000"},{"bit":15,"index":0,"name":"GitcoinContributorStatistics#numGr14ContributionsGte#1"},{"bit":16,"index":0,"name":"GitcoinContributorStatistics#numRoundsContributedToGte#1"},{"bit":17,"index":0,"name":"GitcoinGranteeStatistics#numOwnedGrants#1"},{"bit":18,"index":0,"name":"GitcoinGranteeStatistics#numGrantContributors#10"},{"bit":19,"index":0,"name":"GitcoinGranteeStatistics#numGrantContributors#25"},{"bit":20,"index":0,"name":"GitcoinGranteeStatistics#numGrantContributors#100"},{"bit":21,"index":0,"name":"GitcoinGranteeStatistics#totalContributionAmount#100"},{"bit":22,"index":0,"name":"GitcoinGranteeStatistics#totalContributionAmount#1000"},{"bit":23,"index":0,"name":"GitcoinGranteeStatistics#totalContributionAmount#10000"},{"bit":24,"index":0,"name":"GitcoinGranteeStatistics#numGrantsInEcoAndCauseRound#1"},{"bit":25,"index":0,"name":"Twitter"},{"bit":26,"index":0,"name":"TwitterTweetGT10"},{"bit":27,"index":0,"name":"TwitterFollowerGT100"},{"bit":28,"index":0,"name":"TwitterFollowerGT500"},{"bit":29,"index":0,"name":"TwitterFollowerGTE1000"},{"bit":30,"index":0,"name":"TwitterFollowerGT5000"},{"bit":31,"index":0,"name":"Discord"},{"bit":32,"index":0,"name":"Google"},{"bit":33,"index":0,"name":"Github"},{"bit":34,"index":0,"name":"FiveOrMoreGithubRepos"},{"bit":35,"index":0,"name":"ForkedGithubRepoProvider"},{"bit":36,"index":0,"name":"StarredGithubRepoProvider"},{"bit":37,"index":0,"name":"TenOrMoreGithubFollowers"},{"bit":38,"index":0,"name":"FiftyOrMoreGithubFollowers"},{"bit":39,"index":0,"name":"Facebook"},{"bit":40,"index":0,"name":"FacebookProfilePicture"},{"bit":41,"index":0,"name":"Linkedin"},{"bit":42,"index":0,"name":"Ens"},{"bit":43,"index":0,"name":"POAP"},{"bit":44,"index":0,"name":"Brightid"},{"bit":45,"index":0,"name":"Poh"},{"bit":46,"index":0,"name":"ethPossessionsGte#1"},{"bit":47,"index":0,"name":"ethPossessionsGte#10"},{"bit":48,"index":0,"name":"ethPossessionsGte#32"},{"bit":49,"index":0,"name":"FirstEthTxnProvider"},{"bit":50,"index":0,"name":"EthGTEOneTxnProvider"},{"bit":51,"index":0,"name":"EthGasProvider"},{"bit":52,"index":0,"name":"SnapshotVotesProvider"},{"bit":53,"index":0,"name":"SnapshotProposalsProvider"},{"bit":54,"index":0,"name":"GitPOAP"},{"bit":55,"index":0,"name":"NFT"},{"bit":56,"index":0,"name":"ZkSync"},{"bit":57,"index":0,"name":"ZkSyncEra"},{"bit":58,"index":0,"name":"Lens"},{"bit":59,"index":0,"name":"GnosisSafe"},{"bit":60,"index":0,"name":"Coinbase"},{"bit":61,"index":0,"name":"GuildMember"},{"bit":62,"index":0,"name":"GuildAdmin"},{"bit":63,"index":0,"name":"GuildPassportMember"},{"bit":64,"index":0,"name":"Hypercerts"},{"bit":65,"index":0,"name":"PHIActivitySilver"},{"bit":66,"index":0,"name":"PHIActivityGold"},{"bit":67,"index":0,"name":"HolonymGovIdProvider"}] \ No newline at end of file diff --git a/iam/src/utils/easPassportSchema.ts b/iam/src/utils/easPassportSchema.ts new file mode 100644 index 0000000000..5571f59c9d --- /dev/null +++ b/iam/src/utils/easPassportSchema.ts @@ -0,0 +1,217 @@ +import { + NO_EXPIRATION, + SchemaEncoder, + ZERO_BYTES32, + MultiAttestationRequest, + AttestationRequestData, +} from "@ethereum-attestation-service/eas-sdk"; +import { VerifiableCredential } from "@gitcoin/passport-types"; +import { BigNumber } from "@ethersproject/bignumber"; + +import { fetchPassportScore } from "./scorerService"; +import { encodeEasScore } from "./easStampSchema"; + +import bitMapData from "../static/providerBitMapInfo.json"; + +export type AttestationStampInfo = { + hash: string; + stampInfo: PassportAttestationStamp; + issuanceDate: BigNumber; + expirationDate: BigNumber; +}; + +export type PassportAttestationData = { + providers: BigNumber[]; + info: AttestationStampInfo[]; +}; + +export type PassportAttestationStamp = { + name: string; + index: number; + bit: number; +}; + +export type StampMetadata = { + id: string; + name: string; + groups: { + name: string; + stamps: { + name: string; + }[]; + }[]; +}[]; + +export type Stamp = { + bit: number; + index: number; + name: string; +}; + +// exported to buildProviderBitMap for formatting +export const mapBitMapInfo = (metaData: StampMetadata): PassportAttestationStamp[] => { + let bit = 0; + let index = 0; + + return metaData.flatMap((entry) => + entry.groups.flatMap((group) => + group.stamps.map((stamp) => { + const result = { bit: bit % 256, index, name: stamp.name }; + bit++; + if (bit % 256 === 0) index++; + return result; + }) + ) + ); +}; + +export const buildProviderBitMap = (): Map => { + try { + // const parseBitMapInfo = JSON.parse(bitMapData as unknown as string); + const bitMapInfo = bitMapData as unknown as Stamp[]; + const passportAttestationStampMap: Map = new Map(); + + bitMapInfo.forEach((stamp) => passportAttestationStampMap.set(stamp.name, stamp)); + + return passportAttestationStampMap; + } catch (e) { + console.error(e); + } +}; + +export const formatPassportAttestationData = (credentials: VerifiableCredential[]): PassportAttestationData => { + const passportAttestationStampMap = buildProviderBitMap(); + return credentials.reduce( + (acc: PassportAttestationData, credential: VerifiableCredential) => { + const stampInfo: PassportAttestationStamp = passportAttestationStampMap.get( + credential.credentialSubject.provider + ); + + if (stampInfo) { + const index = stampInfo.index; + if (acc.providers.length <= index) { + // We must add another element to the array of providers + acc.providers.length = index + 1; + acc.providers[index] = BigNumber.from(0); + } + // Shift the bit `1` to the left by the number of bits specified in the stamp info + acc.providers[index] = acc.providers[index].or(BigNumber.from(1).shl(stampInfo.bit)); + + // We decode the original 256-bit hash value from the credential + const hashValue = "0x" + Buffer.from(credential.credentialSubject.hash.split(":")[1], "base64").toString("hex"); + // Get the unix timestamp, the number of milliseconds since January 1, 1970, UTC + const issuanceDate = Math.floor(new Date(credential.issuanceDate).getTime() / 1000); + const expirationDate = Math.floor(new Date(credential.expirationDate).getTime() / 1000); + acc.info.push({ + hash: hashValue, + issuanceDate: BigNumber.from(issuanceDate), + expirationDate: BigNumber.from(expirationDate), + stampInfo: stampInfo, + }); + } + return acc; + }, + { + providers: [], + info: [], + } + ); +}; + +export type AttestationData = { + hashes: string[]; + issuancesDates: BigNumber[]; + expirationDates: BigNumber[]; +}; + +export const sortPassportAttestationData = (attestation: PassportAttestationData): AttestationData => { + attestation.info = attestation.info.sort((a, b) => { + // We want to order first by index position and then by bit order + const indexCompare = a.stampInfo.index - b.stampInfo.index; + if (indexCompare === 0) { + return a.stampInfo.bit - b.stampInfo.bit; + } + return indexCompare; + }); + + const hashes = attestation.info.map((info) => info.hash); + const issuancesDates = attestation.info.map((info) => info.issuanceDate); + const expirationDates = attestation.info.map((info) => info.expirationDate); + + return { + hashes, + issuancesDates, + expirationDates, + }; +}; + +export const encodeEasPassport = (credentials: VerifiableCredential[]): string => { + const attestation = formatPassportAttestationData(credentials); + + const attestationSchemaEncoder = new SchemaEncoder( + "uint256[] providers, bytes32[] hashes, uint64[] issuanceDates, uint64[] expirationDates, uint16 providerMapVersion" + ); + + const { hashes, issuancesDates, expirationDates } = sortPassportAttestationData(attestation); + + const encodedData = attestationSchemaEncoder.encodeData([ + { name: "providers", value: attestation.providers, type: "uint256[]" }, + { name: "hashes", value: hashes, type: "bytes32[]" }, + { name: "issuanceDates", value: issuancesDates, type: "uint64[]" }, + { name: "expirationDates", value: expirationDates, type: "uint64[]" }, + // This will be used later for decoding provider mapping for scoring and within the resolver contract + // Currently set to zero but should be updated whenever providerBitMapInfo.json is updated + { name: "providerMapVersion", value: BigNumber.from(0), type: "uint16" }, + ]); + + return encodedData; +}; + +type ValidatedCredential = { + credential: VerifiableCredential; + verified: boolean; +}; + +export const formatMultiAttestationRequest = async ( + credentials: ValidatedCredential[], + recipient: string +): Promise => { + const defaultRequestData = { + recipient, + expirationTime: NO_EXPIRATION, + revocable: true, + refUID: ZERO_BYTES32, + value: 0, + }; + + const stampRequestData: AttestationRequestData[] = [ + { + ...defaultRequestData, + data: encodeEasPassport( + credentials + .filter(({ verified }) => verified) + .map(({ credential }) => { + return credential; + }) + ), + }, + ]; + + const scoreRequestData: AttestationRequestData[] = [ + { + ...defaultRequestData, + data: encodeEasScore(await fetchPassportScore(recipient)), + }, + ]; + + return [ + { + schema: process.env.EAS_GITCOIN_PASSPORT_SCHEMA, + data: stampRequestData, + }, + { + schema: process.env.EAS_GITCOIN_SCORE_SCHEMA, + data: scoreRequestData, + }, + ]; +}; diff --git a/iam/src/utils/easSchema.ts b/iam/src/utils/easStampSchema.ts similarity index 100% rename from iam/src/utils/easSchema.ts rename to iam/src/utils/easStampSchema.ts diff --git a/iam/src/utils/scorerService.ts b/iam/src/utils/scorerService.ts index f59d3cc092..5d4d14e94d 100644 --- a/iam/src/utils/scorerService.ts +++ b/iam/src/utils/scorerService.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { Score } from "./easSchema"; +import { Score } from "./easStampSchema"; const scorerApiGetScore = `${process.env.SCORER_ENDPOINT}/registry/score/${process.env.ALLO_SCORER_ID}`; // Use public endpoint and static api key to fetch score diff --git a/iam/tsconfig.json b/iam/tsconfig.json index b445df93f4..0450c8ff26 100644 --- a/iam/tsconfig.json +++ b/iam/tsconfig.json @@ -10,6 +10,7 @@ "sourceMap": true, "outDir": "dist", "baseUrl": ".", + "resolveJsonModule": true, "paths": { "*": ["../node_modules/*", "node_modules/*"] }